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

Snapshot import export component options #4610

Merged
merged 21 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
770b28c
duplicate unused components
Steve-Mcl Oct 3, 2024
c305f90
repurpose ExportInstanceComponents as ExportImportComponents
Steve-Mcl Oct 3, 2024
504a6f6
update snapshot controller to support component options
Steve-Mcl Oct 3, 2024
cb8dba1
update snapshot import/export API to support component options
Steve-Mcl Oct 3, 2024
884fde6
update frontend API to support component options
Steve-Mcl Oct 3, 2024
034a7b0
Update UI for snapshot import/export component options
Steve-Mcl Oct 3, 2024
e2dfe8d
add unit tests for import/export snapshot component options
Steve-Mcl Oct 3, 2024
0eaa882
remove .only
Steve-Mcl Oct 3, 2024
388a160
Merge branch 'main' into 4605-snapshot-import-export-options
Steve-Mcl Oct 3, 2024
142a9c3
update docs for snapshot components options
Steve-Mcl Oct 4, 2024
00db9a5
Merge branch '4605-snapshot-import-export-options' of https://github.…
Steve-Mcl Oct 4, 2024
ec96133
Update frontend/src/pages/application/Snapshots/components/dialogs/Sn…
Steve-Mcl Oct 4, 2024
236d1e8
Merge branch 'main' into 4605-snapshot-import-export-options
Steve-Mcl Oct 4, 2024
37bad6d
fix reactivity of env radios
Steve-Mcl Oct 7, 2024
7a53ac8
fix picking same snapshot after clode/cancel
Steve-Mcl Oct 7, 2024
a688498
add data-xxx for e2e testing
Steve-Mcl Oct 7, 2024
bbd2793
explicit error props
Steve-Mcl Oct 7, 2024
d759bfe
snapshot upload/download component options e2e tests
Steve-Mcl Oct 7, 2024
0cf8ba4
ability to grab filename from REGEX test
Steve-Mcl Oct 7, 2024
7d3131e
Merge branch 'main' into 4605-snapshot-import-export-options
Steve-Mcl Oct 7, 2024
7a7fe65
Merge branch 'main' into 4605-snapshot-import-export-options
ppawlowski Oct 9, 2024
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
2 changes: 0 additions & 2 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,6 @@
"frontend/src/components/tables/cells/TeamTypeCell.vue",
"frontend/src/components/tables/cells/UserCell.vue",
"frontend/src/components/tables/cells/UserRoleCell.vue",
"frontend/src/pages/application/components/ExportProjectComponents.vue",
"frontend/src/pages/application/components/ImportProjectComponents.vue",
"frontend/src/pages/application/Debug.vue",
"frontend/src/pages/device/components/DeviceLastSeenBadge.vue",
"frontend/src/pages/device/Overview.vue",
Expand Down
7 changes: 5 additions & 2 deletions config/cypress-shared.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@ module.exports = {
// fileName looks like a regex
debug = debug + JSON.stringify(files)
const filteredList = files.filter(file => !!file.match(re))
return filteredList.length === 1
if (filteredList.length === 1) {
return filteredList[0]
}
return false
}
return false
}).then(result => {
if (!result) {
throw new Error(`${fileName} not found in ${baseDir} ${debug}`)
}
return true
return result
})
},
// Clear all files in the downloads folder
Expand Down
28 changes: 21 additions & 7 deletions docs/user/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,37 @@ A snapshot can be downloaded to your local machine for backup or sharing.

To download a snapshot:

1. Go to the instance's page and select the **Snapshots** tab.
1. Go to the desired application, instance or device overview page and select the **Snapshots** tab.
2. Open the dropdown menu to the right of the snapshot you want to download and
select the **Download Snapshot** option.
click the **Download Snapshot** option to open the download dialog.
3. Select the required components to download.
- **Flows**: Include the snapshot flows
- **Credentials**: Include the snapshot flows credentials
- **Environment Variables**: Include environment variables in the snapshot
- **Keys and Values**: Include the keys and values of the environment variables
- **Keys Only**: Include only the keys of the environment variables
4. Enter a secret to encrypt any credentials in the snapshot (optional, depends on components selected).
5. Click **Download**

## Upload a snapshot

A snapshot can be uploaded to a Node-RED instance in FlowFuse.

To upload a snapshot:

1. Go to the instance's page and select the **Snapshots** tab.
1. Go to the desired instance or device overview page and select the **Snapshots** tab.
2. Click the **Upload Snapshot** button.
3. Select the snapshot file from your local machine.
4. If the snapshot contains credentials, you will be asked to enter the credentials secret.
This is the secret that was used to encrypt the credentials in the snapshot.
5. Update the name and description if required.
6. Click **Upload**
4. Update the name and description if required.
5. Select the components to upload:
- **Flows**: Include the snapshots flows
- **Credentials**: Include the snapshots flows credentials (visible only if the snapshot contains credentials)
- **Environment Variables**: Include environment variables in the snapshot
- **Keys and Values**: Include the keys and values of the environment variables
- **Keys Only**: Include only the keys of the environment variables
6. If the snapshot contains credentials and the `Credentials` component is checked,
you will be asked to enter a Secret. This will be used to later decrypt any credentials in the snapshots flows.
7. Click **Upload**

## Delete a snapshot

Expand Down
119 changes: 100 additions & 19 deletions forge/db/controllers/Snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,22 @@ module.exports = {
* @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.
* @param {Object} [options.components] (Optional) Components to include in the snapshot. Defaults to all components.
* @param {Boolean} [options.components.flows] (Optional) Include flows in the snapshot. Defaults to true.
* @param {Boolean} [options.components.credentials] (Optional) Include credentials in the snapshot. Defaults to true if `options.components.flows` is true, otherwise false.
* @param {'all' | 'keys' | false} [options.components.envVars='all'] (Optional) Env Var options: `'All'` will keep keys and values, `'keys'` will keep only keys, `false` will remove them.
*/
exportSnapshot: async function (app, snapshot, options) {
if (!options.credentialSecret) {
const components = {
flows: options.components?.flows ?? true,
credentials: options.components?.credentials ?? true,
envVars: ['all', 'keys', false].includes(options.components?.envVars) ? options.components.envVars : 'all'
}
if (components.flows === false) {
components.credentials = false // no flows, no credentials!
}

if (components.credentials !== false && !options.credentialSecret) {
return null
}

Expand Down Expand Up @@ -54,22 +67,48 @@ module.exports = {
...snapshot.toJSON()
}

// include/exclude components of the snapshot
if (components.flows === false) {
result.flows = {
flows: [],
credentials: {} // no flows, no credentials!
}
} else if (components.credentials === false) {
result.flows = {
flows: snapshot.flows.flows || [],
credentials: {} // no credentials!
}
}
if (components.envVars === false) {
result.settings.env = {}
} else if (components.envVars === 'keys' && snapshot.settings?.env && Object.keys(snapshot.settings.env).length > 0) {
const keysOnly = Object.keys(snapshot.settings.env).reduce((acc, key) => {
acc[key] = ''
return acc
}, {})
result.settings = Object.assign({}, snapshot.settings, { env: keysOnly })
}

// 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 (components.flows === false || components.credentials === false) {
result.flows.credentials = {}
} else {
// 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)
// 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
},
Expand Down Expand Up @@ -143,8 +182,13 @@ module.exports = {
* @param {*} snapshot - snapshot data
* @param {String} credentialSecret - secret to encrypt credentials with. Can be null if the snapshot does not contain credentials.
* @param {*} user - user who uploaded the snapshot
* @param {Object} [options] - additional options
* @param {Object} [options.components] - Include flows in the snapshot. Defaults to true.
* @param {Boolean} [options.components.flows] - Include flows in the snapshot. Defaults to true.
* @param {Boolean} [options.components.credentials] - Include credentials in the snapshot. Defaults to true if `options.components.flows` is true, otherwise false.
* @param {'all' | 'keys' | false} [options.components.envVars='all'] - Env Var options: `'All'` will keep keys and values, `'keys'` will keep only keys, `false` will remove them.
*/
async uploadSnapshot (app, owner, snapshot, credentialSecret, user) {
async uploadSnapshot (app, owner, snapshot, credentialSecret, user, options) {
// Validate the owner
let ownerType
if (owner.constructor.name === 'Project') {
Expand All @@ -155,33 +199,70 @@ module.exports = {
throw new Error('Invalid owner type')
}

const components = {
flows: options?.components?.flows ?? true,
credentials: options?.components?.credentials ?? true,
envVars: ['all', 'keys', false].includes(options?.components?.envVars) ? options?.components.envVars : 'all'
}
if (components.flows === false) {
components.credentials = false // no flows, no credentials!
}

// shallow clone the snapshot before modifying it
const importSnapshot = Object.assign({}, snapshot)

// include/exclude components of the snapshot
if (components.flows === false) {
importSnapshot.flows = {
flows: [],
credentials: {} // no flows, no credentials!
}
} else if (components.credentials === false) {
importSnapshot.flows = {
flows: snapshot.flows.flows || [],
credentials: {} // no credentials!
}
}
if (components.envVars === false) {
importSnapshot.settings = {
...snapshot.settings,
env: {} // no env vars!
}
} else if (components.envVars === 'keys' && snapshot.settings?.env && Object.keys(snapshot.settings.env).length > 0) {
const keysOnly = Object.keys(snapshot.settings.env).reduce((acc, key) => {
acc[key] = ''
return acc
}, {})
importSnapshot.settings = Object.assign({}, snapshot.settings, { env: keysOnly })
}

const targetCredentialSecret = owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret()) || credentialSecret
// 1. If the snapshot includes credentials but no credentialSecret, we should reject it
// 2. if the snapshot includes credentials and a credentialSecret, we should reencrypt for the owner
if (snapshot.flows.credentials?.$) {
if (importSnapshot.flows.credentials?.$) {
if (!credentialSecret) {
throw new Error('Missing credentialSecret')
}
// Need to re-encrypt the credentials for the target
snapshot.flows.credentials = app.db.controllers.Project.exportCredentials(snapshot.flows.credentials, credentialSecret, targetCredentialSecret)
importSnapshot.flows.credentials = app.db.controllers.Project.exportCredentials(importSnapshot.flows.credentials, credentialSecret, targetCredentialSecret)
}

const ProjectId = ownerType === 'instance' ? owner.id : null
const DeviceId = ownerType === 'device' ? owner.id : null

const snapshotOptions = {
name: snapshot.name,
description: snapshot.description || '',
name: importSnapshot.name,
description: importSnapshot.description || '',
credentialSecret: targetCredentialSecret,
settings: {
settings: snapshot.settings?.settings || {},
env: snapshot.settings?.env || {},
modules: snapshot.settings?.modules || {}
settings: importSnapshot.settings?.settings || {},
env: importSnapshot.settings?.env || {},
modules: importSnapshot.settings?.modules || {}

},
flows: {
flows: snapshot.flows.flows || [],
credentials: snapshot.flows.credentials || { }
flows: importSnapshot.flows.flows || [],
credentials: importSnapshot.flows.credentials || { }
},
ProjectId,
DeviceId,
Expand Down
46 changes: 39 additions & 7 deletions forge/routes/api/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,20 @@ module.exports = async function (app) {
body: {
type: 'object',
properties: {
credentialSecret: { type: 'string' }
credentialSecret: { type: 'string' },
components: {
type: 'object',
properties: {
flows: { type: 'boolean', default: true },
credentials: { type: 'boolean', default: true },
envVars: {
anyOf: [
{ type: 'string', enum: ['all', 'keys'] },
{ type: 'boolean', enum: [false] }
]
}
}
}
}
},
response: {
Expand All @@ -243,7 +256,8 @@ module.exports = async function (app) {
const options = {
credentialSecret: request.body.credentialSecret,
credentials: request.body.credentials,
owner: request.owner // the instance or device that owns the snapshot
owner: request.owner, // the instance or device that owns the snapshot
components: request.body.components
}

if (!options.credentialSecret) {
Expand Down Expand Up @@ -304,7 +318,20 @@ module.exports = async function (app) {
},
required: ['name', 'flows', 'settings']
},
credentialSecret: { type: 'string' }
credentialSecret: { type: 'string' },
components: {
type: 'object',
properties: {
flows: { type: 'boolean', default: true },
credentials: { type: 'boolean', default: true },
envVars: {
anyOf: [
{ type: 'string', enum: ['all', 'keys'] },
{ type: 'boolean', enum: [false] }
]
}
}
}
}
},
response: {
Expand All @@ -323,12 +350,17 @@ module.exports = async function (app) {
reply.code(400).send({ code: 'bad_request', error: 'owner and snapshot are mandatory in the body' })
return
}
if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) {
reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' })
return
if (request.body.components?.credentials !== false) {
if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) {
reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' })
return
}
}
try {
const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User)
const options = {
components: request.body.components || null
}
const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User, options)
if (!newSnapshot) {
throw new Error('Failed to upload snapshot')
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/api/snapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ const exportSnapshot = (snapshotId, options) => {
* @param {Object} snapshot - snapshot object to import
* @param {String} [credentialSecret] - secret to use when decrypting credentials in the snapshot object (optional/only required when the snapshot contains credentials)
*/
const importSnapshot = async (ownerId, ownerType, snapshot, credentialSecret) => {
const importSnapshot = async (ownerId, ownerType, snapshot, credentialSecret, options) => {
return client.post('/api/v1/snapshots/import', {
ownerId,
ownerType,
snapshot,
credentialSecret
credentialSecret,
components: options?.components
}).then(res => {
const props = {
'snapshot-id': res.data.id
Expand Down
Loading
Loading