Skip to content

Commit

Permalink
Merge pull request #2835 from FlowFuse/2484-any-snapshot-to-app-owned…
Browse files Browse the repository at this point in the history
…-device

Add ability to push any snapshot to application owned device
  • Loading branch information
Steve-Mcl authored Oct 9, 2023
2 parents 7156535 + 07a9d83 commit c8f484b
Show file tree
Hide file tree
Showing 23 changed files with 460 additions and 153 deletions.
8 changes: 6 additions & 2 deletions docs/device-agent/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,19 @@ Whilst in Developer Mode the device will not receive new updates from the platfo

**Creating a Device Snapshot**

Device Snapshots are not currently supported when editing flows on a device that is assigned to an application.
This feature is on the roadmap for a future release.
To create a snapshot from an application owned device use the **Create Snapshot** button
in the Developer Mode options panel.

You will be prompted to give the snapshot a name and description. See [Snapshots](../user/snapshots.md) for more information
about working with snapshots.

### Important Notes

* Remote access to the editor requires Device Agent v0.8.0 or later.
* The Web UI requires Device Agent v0.9.0 or later.
* Assigning a device to an application requires Device Agent v1.11.0 and FlowFuse v1.11.0 or later.
* Snapshots of devices assigned to an application are supported in FlowFuse V1.12.0 or later.
* Deploying a snapshot from a different instance or device to an application owned device is supported in FlowFuse V1.13.0 or later.
* When a device is assigned to an instance:
* It must first have a snapshot applied before editor access is possible.
* Disabling Developer Mode will cause the device to check-in with the platform. If the device flows have changed, it will be reloaded with the current target snapshot assigned to that device, causing any changes made in Developer Mode to be overwritten. Therefore, it is recommended to create a snapshot of the changes before disabling Developer Mode.
Expand Down
19 changes: 16 additions & 3 deletions docs/user/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,28 @@ Snapshots are used to identify a version of the Node-RED instance that should be
out to any connected devices. This allows you to develop you flows in FlowFuse
and only push out to the devices when it is ready.

To set the **Device Target**:
### Instance owned devices
To set the **Device Target** of an instance owned device:

1. Go to the instance's page and select **Snapshots** in the sidebar.
1. Go to the instance's page and select the **Snapshots** tab.
2. Open the dropdown menu to the right of the snapshot you want to set as the
device target and select the **Set as Device Target** option.
3. You will be asked to confirm - click **Set Target** to continue.

This will cause the snapshot to be pushed out to any connected devices the
next time they check in.
next time it checks in.

### Application owned devices
To set the **Device Target** of an application owned device:

1. Go to the devices's page and select the **Snapshots** tab.
2. In the list of snapshots available, a "Deploy Snapshot" button will be displayed
for each snapshot as you hover over it.
3. You will be asked to confirm - click **Set Target** to continue.

This will cause the snapshot to be pushed out to the device the
next time it checks in.


## Creating a Snapshot from a device

Expand Down
16 changes: 16 additions & 0 deletions forge/auditLog/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ const { generateBody, triggerObject } = require('./formatters')

module.exports = {
getLoggers (app) {
/**
* @name ApplicationAuditLog
* @alias ApplicationAuditLog
* @namespace
*/
const application = {
async created (actionedBy, error, application) {
await log('application.created', actionedBy, application?.id, generateBody({ error, application }))
Expand All @@ -16,6 +21,17 @@ module.exports = {
},
async assigned (actionedBy, error, application, device) {
await log('application.device.assigned', actionedBy, application?.id, generateBody({ error, application, device }))
},
snapshot: {
async created (actionedBy, error, application, device, snapshot) {
await log('application.device.snapshot.created', actionedBy, application?.id, generateBody({ error, device, snapshot }))
},
async deleted (actionedBy, error, application, device, snapshot) {
await log('application.device.snapshot.deleted', actionedBy, application?.id, generateBody({ error, device, snapshot }))
},
async deviceTargetSet (actionedBy, error, application, device, snapshot) {
await log('application.device.snapshot.device-target-set', actionedBy, application?.id, generateBody({ error, device, snapshot }))
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion forge/comms/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class DeviceCommsHandler {
if (status.id && status.status) {
const deviceId = status.id
const device = await this.app.db.models.Device.byId(deviceId)
const isApplicationOwned = device.ownerType === 'application' && device.Application?.id
if (!device) {
// TODO: log invalid device
return
Expand Down Expand Up @@ -94,7 +95,7 @@ class DeviceCommsHandler {
if (payload.snapshot !== (targetSnapshot?.hashid || null)) {
// The Snapshot is incorrect
sendUpdateCommand = true
} else if (targetSnapshot && payload.project !== (targetSnapshot?.ProjectId || null)) {
} else if (targetSnapshot && !isApplicationOwned && payload.project !== (targetSnapshot?.ProjectId || null)) {
// The project the device is reporting it belongs to does not match the target Snapshot parent project
sendUpdateCommand = true
}
Expand Down
28 changes: 17 additions & 11 deletions forge/db/controllers/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@ module.exports = {
sendDeviceUpdateCommand: async function (app, device) {
if (app.comms) {
let snapshotId = device.targetSnapshot?.hashid || null
const isApplicationOwned = device.ownerType === 'application' && device.Application?.id
if (snapshotId) {
// device.targetSnapshot is a limited view so we need to load the it from the db
// check it has an associated project and that it matches the device's project
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
// 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 (isApplicationOwned === false) {
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 = {
Expand All @@ -54,10 +57,13 @@ module.exports = {
// 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.ownerType === 'application' && device.Application?.hashid && payload.snapshot === null) {
delete payload.project // exclude project property to avoid triggering the wrong kind of update
payload.application = device.Application?.hashid
payload.snapshot = '0' // '0' is temporary value to indicate that the device should start in application mode with starter flows
if (isApplicationOwned) {
delete payload.project // exclude project property to avoid triggering the wrong kind of update on the device
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
}
app.comms.devices.sendCommand(device.Team.hashid, device.hashid, 'update', payload)
}
Expand Down Expand Up @@ -89,8 +95,8 @@ module.exports = {
let snapshotName

if (device.ownerType === 'application') {
snapshotId = '0' // '0' is temporary value to indicate that the device should start in application mode with starter flows
snapshotName = 'None'
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 {
Expand Down
9 changes: 4 additions & 5 deletions forge/db/controllers/ProjectSnapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,10 @@ module.exports = {
* Export specific snapshot.
* @param {*} app
* @param {*} project project-originator of this snapshot
* @snapshot {*} snapshot snapshot object to export
* @options {*} options.
* Must include: credentialSecret.
* Optional: credentials of the target Project (either encrypted or raw).
* If not given, credentials of the current project will be re-encrypted, with credentialSecret.
* @param {*} snapshot snapshot object to export
* @param {Object} options
* @param {String} options.credentialSecret secret to encrypt credentials with
* @param {Object} [options.credentials] (Optional) credentials to export. If omitted, credentials of the current project will be re-encrypted, with credentialSecret.
*/
exportSnapshot: async function (app, project, snapshot, options) {
let snapshotObj = snapshot.get()
Expand Down
2 changes: 1 addition & 1 deletion forge/db/models/ProjectSnapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ module.exports = {
ownerType: {
type: DataTypes.VIRTUAL,
get () {
return this.Project?.id ? 'instance' : (this.Device?.hashid ? 'device' : null)
return this.ProjectId ? 'instance' : (this.DeviceId ? 'device' : null)
}
}
},
Expand Down
1 change: 1 addition & 0 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const Permissions = {
'device:snapshot:list': { description: 'List Device Snapshots', role: Roles.Viewer },
'device:snapshot:read': { description: 'View a Device Snapshot', role: Roles.Viewer },
'device:snapshot:delete': { description: 'Delete Device Snapshot', role: Roles.Owner },
'device:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member },

// Project Types
'project-type:create': { description: 'Create a ProjectType', role: Roles.Admin },
Expand Down
45 changes: 33 additions & 12 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ module.exports = async function (app) {
}, async (request, reply) => {
let sendDeviceUpdate = false
const device = request.device
/** @type {import('../../auditLog/formatters').UpdatesCollection} */
const updates = new app.auditLog.formatters.UpdatesCollection()
let assignToProject = null
let assignToApplication = null
let postOpAuditLogAction = null
Expand Down Expand Up @@ -366,8 +368,8 @@ module.exports = async function (app) {
// unassign from application
await device.setApplication(null)
await commonUpdates()
await app.auditLog.Team.team.device.unassigned(request.session.User, null, request.device?.Team, oldApplication, request.device)
await app.auditLog.Application.application.device.unassigned(request.session.User, null, oldApplication, request.device)
await app.auditLog.Team.team.device.unassigned(request.session.User, null, device?.Team, oldApplication, device)
await app.auditLog.Application.application.device.unassigned(request.session.User, null, oldApplication, device)
}

if (unassignInstance && device.Project) {
Expand All @@ -380,8 +382,8 @@ module.exports = async function (app) {
device.mode = 'autonomous'
await device.save()

await app.auditLog.Team.team.device.unassigned(request.session.User, null, request.device?.Team, oldProject, request.device)
await app.auditLog.Project.project.device.unassigned(request.session.User, null, oldProject, request.device)
await app.auditLog.Team.team.device.unassigned(request.session.User, null, device?.Team, oldProject, device)
await app.auditLog.Project.project.device.unassigned(request.session.User, null, oldProject, device)
}
} else if (assignTo === 'instance') {
// ### Add device to instance ###
Expand Down Expand Up @@ -435,26 +437,40 @@ module.exports = async function (app) {
}
} else {
// ### Modify device properties ###
let changed = false
const updates = new app.auditLog.formatters.UpdatesCollection()
if (request.body.targetSnapshot !== undefined && request.body.targetSnapshot !== device.targetSnapshotId) {
// get snapshot from db
const targetSnapshot = await app.db.models.ProjectSnapshot.byId(request.body.targetSnapshot)
if (!targetSnapshot) {
reply.code(400).send({ code: 'invalid_snapshot', error: 'invalid snapshot' })
return
}
// store original value for later audit log
const originalSnapshotId = device.targetSnapshotId

// Update the targetSnapshot of the device
await app.db.models.Device.update({ targetSnapshotId: targetSnapshot.id }, {
where: {
id: device.id
}
})
await app.auditLog.Application.application.device.snapshot.deviceTargetSet(request.session.User, null, device.Application, device, targetSnapshot)
updates.push('targetSnapshotId', originalSnapshotId, device.targetSnapshotId)
sendDeviceUpdate = true
}
if (request.body.name !== undefined && request.body.name !== device.name) {
updates.push('name', device.name, request.body.name)
device.name = request.body.name
sendDeviceUpdate = true
changed = true
}
if (request.body.type !== undefined && request.body.type !== device.type) {
updates.push('type', device.type, request.body.type)
device.type = request.body.type
sendDeviceUpdate = true
changed = true
}
if (changed) {
await app.auditLog.Team.team.device.updated(request.session.User, null, device.Team, request.device, updates)
}
}
await device.save()

// save and send update
await device.save()
const updatedDevice = await app.db.models.Device.byId(device.id)
if (sendDeviceUpdate) {
await app.db.controllers.Device.sendDeviceUpdateCommand(updatedDevice)
Expand All @@ -471,6 +487,11 @@ module.exports = async function (app) {
await app.auditLog.Application.application.device.assigned(request.session.User, null, assignToApplication, updatedDevice)
break
}
// fulfil team audit log updates
if (updates.length > 0) {
await app.auditLog.Team.team.device.updated(request.session.User, null, device.Team, device, updates)
}

reply.send(app.db.views.Device.device(updatedDevice))
})

Expand Down
22 changes: 15 additions & 7 deletions forge/routes/api/deviceLive.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ module.exports = async function (app) {
})

app.get('/snapshot', async (request, reply) => {
const device = request.device || null
const isApplicationOwned = device?.ownerType === 'application' // && 'EE'?
if (!request.device.targetSnapshot) {
// determine is device is in application mode? if so, return a default snapshot to permit the user to generate flows
const device = request.device || null
const ownerIsApplication = device?.ownerType === 'application' // && 'EE'?
if (ownerIsApplication) {
if (isApplicationOwned) {
const DEFAULT_APP_SNAPSHOT = {
id: '0',
name: 'Starter Snapshot',
Expand Down Expand Up @@ -125,12 +125,20 @@ module.exports = async function (app) {
...snapshot.settings,
...snapshot.flows
}
const getSecret = async () => {
// default to project in the absence of ownerType
if (snapshot.ownerType === 'instance' || !snapshot.ownerType) {
return await (await snapshot.getProject()).getCredentialSecret()
} else {
return (await snapshot.getDevice()).credentialSecret
}
}
if (result.credentials) {
// Need to re-encrypt these credentials from the Project secret
// to the Device secret
const projectSecret = await (await snapshot.getProject()).getCredentialSecret()
// Need to re-encrypt these credentials from the source secret
// to the target Device secret
const secret = await getSecret()
const deviceSecret = request.device.credentialSecret
result.credentials = app.db.controllers.Project.exportCredentials(result.credentials, projectSecret, deviceSecret)
result.credentials = app.db.controllers.Project.exportCredentials(result.credentials, secret, deviceSecret)
}
reply.send(result)
} else {
Expand Down
Loading

0 comments on commit c8f484b

Please sign in to comment.