diff --git a/.eslintrc b/.eslintrc index 5f678417bd..e1f5da1104 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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", diff --git a/config/cypress-shared.config.js b/config/cypress-shared.config.js index d7f8b56f8e..350360ffc7 100644 --- a/config/cypress-shared.config.js +++ b/config/cypress-shared.config.js @@ -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 diff --git a/docs/user/snapshots.md b/docs/user/snapshots.md index 550d992d40..4479206c19 100644 --- a/docs/user/snapshots.md +++ b/docs/user/snapshots.md @@ -44,9 +44,17 @@ 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 @@ -54,13 +62,19 @@ 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 diff --git a/forge/db/controllers/Snapshot.js b/forge/db/controllers/Snapshot.js index 3ed3dfa7c6..7bc7f13ce1 100644 --- a/forge/db/controllers/Snapshot.js +++ b/forge/db/controllers/Snapshot.js @@ -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 } @@ -54,6 +67,28 @@ 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_')) { @@ -61,15 +96,19 @@ module.exports = { } }) - // 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 }, @@ -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') { @@ -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, diff --git a/forge/routes/api/snapshot.js b/forge/routes/api/snapshot.js index 4ab2de5314..7f2631f0dc 100644 --- a/forge/routes/api/snapshot.js +++ b/forge/routes/api/snapshot.js @@ -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: { @@ -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) { @@ -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: { @@ -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') } diff --git a/frontend/src/api/snapshots.js b/frontend/src/api/snapshots.js index 8cd5986b5d..b438388268 100644 --- a/frontend/src/api/snapshots.js +++ b/frontend/src/api/snapshots.js @@ -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 diff --git a/frontend/src/components/dialogs/SnapshotImportDialog.vue b/frontend/src/components/dialogs/SnapshotImportDialog.vue index 91fbeafd82..3cbad24878 100644 --- a/frontend/src/components/dialogs/SnapshotImportDialog.vue +++ b/frontend/src/components/dialogs/SnapshotImportDialog.vue @@ -15,7 +15,6 @@ - Credentials Secret Name Description @@ -23,6 +22,14 @@