Skip to content

Commit

Permalink
Merge pull request #3833 from FlowFuse/3828-snapshots-api
Browse files Browse the repository at this point in the history
Snapshots api
  • Loading branch information
Steve-Mcl authored May 8, 2024
2 parents fe2e18f + 10d55af commit 9395ad5
Show file tree
Hide file tree
Showing 13 changed files with 1,088 additions and 135 deletions.
3 changes: 3 additions & 0 deletions forge/auditLog/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ module.exports = {
async deleted (actionedBy, error, application, device, snapshot) {
await log('application.device.snapshot.deleted', actionedBy, application?.id, generateBody({ error, device, snapshot }))
},
async exported (actionedBy, error, application, device, snapshot) {
await log('application.device.snapshot.exported', 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
29 changes: 2 additions & 27 deletions forge/db/controllers/ProjectSnapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,33 +500,8 @@ module.exports = {
* @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) {
if (!options.credentialSecret) {
return null
}
if (snapshot.UserId && !snapshot.User) {
await snapshot.reload({ include: [app.db.models.User] })
}

const result = {
...snapshot.toJSON()
}

const serviceEnv = ['FF_INSTANCE_ID', 'FF_INSTANCE_NAME', 'FF_PROJECT_ID', 'FF_PROJECT_NAME']
serviceEnv.forEach((key) => {
delete result.settings.env[key]
})

// use the secret stored in the snapshot, if available...
const projectSecret = result.credentialSecret || await project.getCredentialSecret()
const credentials = options.credentials ? options.credentials : result.flows.credentials

// if provided credentials already encrypted: "exportCredentials" will just return the same credentials
// if provided credentials are raw: "exportCredentials" will encrypt them with the secret provided
// if credentials are not provided: project's flows credentials will be used, they will be encrypted with either the provided secret or a new one
const keyToDecrypt = (options.credentials && options.credentials.$) ? options.credentialSecret : projectSecret
result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret)

return result
options.owner = project
return app.db.controllers.Snapshot.exportSnapshot(snapshot, options)
},

getDeviceAutoSnapshots: deviceAutoSnapshotUtils.getAutoSnapshots,
Expand Down
110 changes: 110 additions & 0 deletions forge/db/controllers/Snapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
module.exports = {
/**
* Get a snapshot by ID
* @param {*} app - app instance
* @param {*} snapshotId
*/
async getSnapshot (app, snapshotId) {
return await app.db.models.Snapshot.byId(snapshotId)
},

/**
* Export specific snapshot.
* @param {*} app - app instance
* @param {*} owner project/device-originator of this snapshot
* @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 owner will be re-encrypted with the provided `credentialSecret`.
* @param {Object} [options.owner] (Optional) The owner project or device. If omitted, the snapshot's owner will be obtained from the database.
*/
exportSnapshot: async function (app, snapshot, options) {
if (!options.credentialSecret) {
return null
}

let owner = options.owner
if (!owner) {
if (snapshot.ownerType === 'device') {
owner = await snapshot.getDevice()
} else if (snapshot.ownerType === 'instance') {
owner = await snapshot.getProject()
} else {
return null
}
}

// ensure the owner is of the correct model type
if (snapshot.ownerType === 'device' && owner?.constructor.name !== 'Device') {
return null
}
if (snapshot.ownerType === 'instance' && owner?.constructor.name !== 'Project') {
return null
}

// ensure the snapshot has the User association loaded
if (snapshot.UserId && !snapshot.User) {
await snapshot.reload({ include: [app.db.models.User] })
}

const result = {
...snapshot.toJSON()
}

// loop keys of result.settings.env and remove any that match FF_*
Object.keys(result.settings.env).forEach((key) => {
if (key.startsWith('FF_')) {
delete result.settings.env[key]
}
})

// use the secret stored in the snapshot, if available...
const currentSecret = result.credentialSecret || owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret())
const credentials = options.credentials ? options.credentials : result.flows.credentials

// if provided credentials already encrypted: "exportCredentials" will just return the same credentials
// if provided credentials are raw: "exportCredentials" will encrypt them with the secret provided
// if credentials are not provided: project's flows credentials will be used, they will be encrypted with the provided secret
const keyToDecrypt = (options.credentials && options.credentials.$) ? options.credentialSecret : currentSecret
result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret)

return result
},

/**
* Delete a snapshot
* @param {*} app - app instance
* @param {*} snapshot - snapshot object
*/
async deleteSnapshot (app, snapshot) {
let owner

if (snapshot.ownerType === 'device') {
owner = await snapshot.getDevice()
} else if (snapshot.ownerType === 'instance') {
owner = await snapshot.getProject()
} else {
return false
}

const deviceSettings = await owner.getSetting('deviceSettings') || {
targetSnapshot: null
}
if (deviceSettings.targetSnapshot === snapshot.id) {
// We're about to delete the active snapshot for this device
await owner.updateSetting('deviceSettings', {
targetSnapshot: null
})
// The cascade relationship will ensure owner.targetSnapshotId is cleared
if (app.comms) {
const team = await owner.getTeam()
app.comms.devices.sendCommandToProjectDevices(team.hashid, owner.id, 'update', {
snapshot: null
})
}
}

await snapshot.destroy()
return true
}
}
1 change: 1 addition & 0 deletions forge/db/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const modelTypes = [
'ProjectStack',
'ProjectTemplate',
'ProjectSnapshot',
'Snapshot',
'Device',
'BrokerClient',
'StorageCredentials',
Expand Down
165 changes: 100 additions & 65 deletions forge/db/views/ProjectSnapshot.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,99 @@
module.exports = function (app) {
app.addSchema({
$id: 'Snapshot',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
user: { $ref: 'UserSummary' },
modules: { type: 'object', additionalProperties: true },
ownerType: { type: 'string' },
deviceId: { type: 'string' },
projectId: { type: 'string' },
device: { $ref: 'DeviceSummary' },
project: { $ref: 'InstanceSummary' }
let app

module.exports = {
init: (appInstance) => {
app = appInstance
app.addSchema({
$id: 'SnapshotSummary',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' }
}
})
app.addSchema({
$id: 'Snapshot',
type: 'object',
allOf: [{ $ref: 'SnapshotSummary' }],
properties: {
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
user: { $ref: 'UserSummary' },
modules: { type: 'object', additionalProperties: true },
ownerType: { type: 'string' },
deviceId: { type: 'string' },
projectId: { type: 'string' },
device: { $ref: 'DeviceSummary' },
project: { $ref: 'InstanceSummary' }
}
})
app.addSchema({
$id: 'SnapshotAndSettings',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
user: { $ref: 'UserSummary' },
exportedBy: { $ref: 'UserSummary' },
ownerType: { type: 'string' },
settings: {
type: 'object',
properties: {
settings: { type: 'object', additionalProperties: true },
env: { type: 'object', additionalProperties: true },
modules: { type: 'object', additionalProperties: true }
}
}
}
})
app.addSchema({
$id: 'FullSnapshot',
type: 'object',
allOf: [{ $ref: 'SnapshotAndSettings' }],
properties: {
flows: {
type: 'object',
properties: {
flows: { type: 'array', items: { type: 'object', additionalProperties: true } }
}
}
}
})
app.addSchema({
$id: 'ExportedSnapshot',
type: 'object',
allOf: [{ $ref: 'SnapshotAndSettings' }],
properties: {
flows: {
type: 'object',
properties: {
flows: { type: 'array', items: { type: 'object', additionalProperties: true } },
credentials: { type: 'object', additionalProperties: true }
}
}
}
})
},

snapshotSummary (snapshot) {
if (snapshot) {
const result = snapshot.toJSON ? snapshot.toJSON() : snapshot
const filtered = {
id: result.hashid,
name: result.name,
description: result.description || ''
}
return filtered
} else {
return null
}
})
function snapshot (snapshot) {
},

snapshot (snapshot) {
if (snapshot) {
const result = snapshot.toJSON ? snapshot.toJSON() : snapshot
const filtered = {
Expand Down Expand Up @@ -46,45 +122,9 @@ module.exports = function (app) {
} else {
return null
}
}
app.addSchema({
$id: 'SnapshotSummary',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' }
}
})
function snapshotSummary (snapshot) {
if (snapshot) {
const result = snapshot.toJSON ? snapshot.toJSON() : snapshot
const filtered = {
id: result.hashid,
name: result.name,
description: result.description || ''
}
return filtered
} else {
return null
}
}
app.addSchema({
$id: 'ExportedSnapshot',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
user: { $ref: 'UserSummary' },
exportedBy: { $ref: 'UserSummary' },
flows: { type: 'object', additionalProperties: true },
settings: { type: 'object', additionalProperties: true }
}
})
function snapshotExport (snapshot, exportedBy) {
},

snapshotExport (snapshot, exportedBy) {
if (snapshot) {
const result = snapshot.toJSON ? snapshot.toJSON() : snapshot
const filtered = {
Expand All @@ -94,7 +134,8 @@ module.exports = function (app) {
createdAt: result.createdAt,
updatedAt: result.updatedAt,
flows: result.flows,
settings: result.settings
settings: result.settings,
ownerType: result.ownerType
}
if (snapshot.User) {
filtered.user = app.db.views.User.userSummary(snapshot.User)
Expand All @@ -108,10 +149,4 @@ module.exports = function (app) {
return null
}
}

return {
snapshot,
snapshotSummary,
snapshotExport
}
}
10 changes: 9 additions & 1 deletion forge/db/views/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,16 @@ const modelTypes = [

async function register (app, viewType, viewModule) {
module.exports[viewType] = {}
if (typeof viewModule === 'function') {
if (typeof viewModule === 'object' && typeof viewModule.init === 'function') {
// New style:
// - views export an object with an init function
// - allows the views to contain their own schema definitions
// - allows type inference to work via /** import('path_to_view') */ for
// static analysis, type checking and DX improvements
viewModule.init(app)
module.exports[viewType] = viewModule
} else if (typeof viewModule === 'function') {
// Current style:
// - views export a function that is called to get the view functions back
// - allows the views to contain their own schema definitions as well
module.exports[viewType] = viewModule(app)
Expand Down
6 changes: 6 additions & 0 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ const Permissions = {
'device:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member },
'device:audit-log': { description: 'View a Device Audit Log', role: Roles.Viewer },

// Snapshots (common)
'snapshot:meta': { description: 'View a Snapshot', role: Roles.Viewer },
'snapshot:full': { description: 'View full snapshot details excluding credentials', role: Roles.Member },
'snapshot:export': { description: 'Export a snapshot including credentials', role: Roles.Member },
'snapshot:delete': { description: 'Delete a Snapshot', role: Roles.Owner },

// Project Types
'project-type:create': { description: 'Create a ProjectType', role: Roles.Admin },
'project-type:list': { description: 'List all ProjectTypes' },
Expand Down
Loading

0 comments on commit 9395ad5

Please sign in to comment.