Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions forge/auditLog/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ module.exports = {
async updated (actionedBy, error, project, snapshot, updates) {
await log('project.snapshot.updated', actionedBy, project?.id, generateBody({ error, project, snapshot, updates }))
},
async rolledBack (actionedBy, error, project, snapshot) {
await log('project.snapshot.rolled-back', actionedBy, project?.id, generateBody({ error, project, snapshot }))
async rolledBack (actionedBy, error, project, snapshot, info) {
await log('project.snapshot.rolled-back', actionedBy, project?.id, generateBody({ error, project, snapshot, info }))
},
async deleted (actionedBy, error, project, snapshot) {
await log('project.snapshot.deleted', actionedBy, project?.id, generateBody({ error, project, snapshot }))
Expand Down
7 changes: 4 additions & 3 deletions forge/routes/api/projectActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ module.exports = async function (app) {
body: {
type: 'object',
properties: {
snapshot: { type: 'string' }
snapshot: { type: 'string' },
mergeEnvVars: { type: 'boolean', default: false }
},
required: ['snapshot']
},
Expand Down Expand Up @@ -258,8 +259,8 @@ module.exports = async function (app) {
restartProject = true
}
await app.db.controllers.Project.setInflightState(request.project, 'rollback')
await app.db.controllers.Project.importProjectSnapshot(request.project, snapshot)
await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, null, request.project, snapshot)
await app.db.controllers.Project.importProjectSnapshot(request.project, snapshot, { mergeEnvVars: request.body.mergeEnvVars })
await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, null, request.project, snapshot, { mergeEnvVars: request.body.mergeEnvVars })
if (restartProject) {
await app.db.controllers.Project.setInflightState(request.project, 'restarting')
await app.containers.restartFlows(request.project)
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/api/instances.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,10 @@ const getInstanceDeviceSettings = async (instanceId) => {
const updateInstanceDeviceSettings = async (instanceId, settings) => {
return client.post(`/api/v1/projects/${instanceId}/devices/settings`, settings).then(res => res.data)
}
const rollbackInstance = async (instanceId, snapshotId) => {
const rollbackInstance = async (instanceId, snapshotId, { mergeEnvVars } = {}) => {
const data = {
snapshot: snapshotId
snapshot: snapshotId,
mergeEnvVars
}
return client.post(`/api/v1/projects/${instanceId}/actions/rollback`, data).then(res => res.data)
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/projectSnapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ const create = async (projectId, options) => {
})
}

const rollbackSnapshot = async (instanceId, snapshotId) => {
const rollbackSnapshot = async (instanceId, snapshotId, options) => {
product.capture('ff-snapshot-rollback', {
'snapshot-id': snapshotId
}, {
instance: instanceId
})
return instanceApi.rollbackInstance(instanceId, snapshotId)
return instanceApi.rollbackInstance(instanceId, snapshotId, options)
}

/**
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/audit-log/AuditEntryVerbose.vue
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@
</template>
<template v-else-if="entry.event === 'project.snapshot.rolled-back' || entry.event === 'project.snapshot.rollback'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.project && entry.body.snapshot">Instance '{{ entry.body.project?.name }}' has been rolled back to the Snapshot '{{ entry.body.snapshot?.name }}'.</span>
<span v-if="!error && entry.body?.project && entry.body.snapshot">Instance '{{ entry.body.project?.name }}' has been rolled back to the Snapshot '{{ entry.body.snapshot?.name }}'<template v-if="entry.body.info"> and its environment variables were {{ entry.body.info.mergeEnvVars ? 'merged' : 'replaced' }}</template>.</span>
<span v-else-if="!error">Instance data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'project.snapshot.deleted'">
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/components/dialogs/RollbackSnapshotDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<template>
<div class="space-y-4" data-el="rollback-snapshot-dialog">
<p>
This will overwrite the current instance.
All changes to the flows and settings made since the last snapshot will be lost.
</p>
<ff-radio-group
v-model="envVarsMode"
data-form="env-vars-mode"
label="Environment variables"
orientation="vertical"
multiline-description
:options="envVarsOptions"
/>
<p>Are you sure you want to deploy to this snapshot?</p>
</div>
</template>

<script>
export default {
name: 'RollbackSnapshotDialog',
emits: ['merge-env-vars-changed'],
data () {
return {
envVarsMode: 'merge',
envVarsOptions: [
{
value: 'merge',
label: 'Merge',
description: 'Adds variables from the snapshot that the instance doesn\'t have.\nVariables the instance already has keep their current values, and none are removed.'
},
{
value: 'replace',
label: 'Replace',
description: 'All instance variables are replaced by those in the snapshot. \nMissing ones are added, existing ones take the snapshot\'s values, and ones not in the snapshot are removed.'
}
]
}
},
watch: {
envVarsMode (value) {
this.$emit('merge-env-vars-changed', value === 'merge')
}
}
}
</script>
17 changes: 13 additions & 4 deletions frontend/src/mixins/Snapshots.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { markRaw } from 'vue'

import InstanceApi from '../api/instances.js'
import SnapshotApi from '../api/projectSnapshots.js'
import SnapshotsApi from '../api/snapshots.js'
import RollbackSnapshotDialog from '../components/dialogs/RollbackSnapshotDialog.vue'
import { downloadData } from '../composables/Download.js'
import Alerts from '../services/alerts.js'
import Dialog from '../services/dialog.js'
Expand All @@ -23,17 +26,23 @@ export default {
},
// snapshot actions - rollback
showRollbackDialog (snapshot, alterLoadingState = false) {
let mergeEnvVars = true
return new Promise((resolve) => {
Dialog.show({
header: 'Restore Snapshot',
kind: 'danger',
text: `This will overwrite the current instance.
All changes to the flows, settings and environment variables made since the last snapshot will be lost.
Are you sure you want to deploy to this snapshot?`,
is: {
component: markRaw(RollbackSnapshotDialog),
on: {
'merge-env-vars-changed' (value) {
mergeEnvVars = value
}
}
},
confirmLabel: 'Confirm'
}, async () => {
if (alterLoadingState) this.loading = true
return SnapshotApi.rollbackSnapshot(this.instance.id, snapshot.id)
return SnapshotApi.rollbackSnapshot(this.instance.id, snapshot.id, { mergeEnvVars })
.then(() => {
Alerts.emit('Successfully deployed snapshot.', 'confirmation')
resolve()
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4894,6 +4894,8 @@ export interface paths {
content: {
"application/json": {
snapshot: string;
/** @default false */
mergeEnvVars?: boolean;
};
};
};
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/ui-components/components/form/RadioButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<input type="radio" :value="value">
<span ref="input" class="checkbox" :checked="checked" tabindex="0" @keydown.space.prevent="select(value)" />
<label>{{ label }}</label>
<p v-if="description && !hideDescription" class="ff-description">{{ description }}</p>
<p v-if="description && !hideDescription" class="ff-description" :class="{ 'ff-description--multiline': multilineDescription }">{{ description }}</p>
</label>
</template>

Expand Down Expand Up @@ -35,6 +35,11 @@ export default {
default: false,
type: Boolean
},
// Render line breaks in the description as new lines
multilineDescription: {
default: false,
type: Boolean
},
// Full-width tappable tile layout, with the label stacked above its description
tile: {
default: false,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/ui-components/components/form/RadioGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
:description="option.description"
:disabled="option.disabled"
:hide-description="orientation === 'horizontal'"
:multiline-description="multilineDescription"
:tile="tile"
@select="select"
/>
Expand Down Expand Up @@ -39,6 +40,11 @@ export default {
default: null,
type: Array
},
// Render line breaks in the option descriptions as new lines
multilineDescription: {
default: false,
type: Boolean
},
// Full-width tappable tile layout, with each label stacked above its description
tile: {
default: false,
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/ui-components/stylesheets/ff-core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ p code {
.ff-description {
font-size: $ff-funit-sm;
color: var(--ff-color-text-subtle);

&.ff-description--multiline {
white-space: pre-line;
}
}

hr {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ describe('FlowFuse - Instance Snapshots', () => {

cy.get('[data-el="platform-dialog"]').should('be.visible')
cy.get('[data-el="platform-dialog"] .ff-dialog-header').contains('Restore Snapshot')
cy.get('[data-el="platform-dialog"] [data-form="env-vars-mode"]').should('exist')

// find .ff-btn--danger with text "Confirm" and click it
cy.get('[data-el="platform-dialog"] .ff-btn--danger').contains('Confirm').click()
Expand All @@ -702,6 +703,7 @@ describe('FlowFuse - Instance Snapshots', () => {
const body = interception.request.body
expect(body).to.have.property('snapshot')
expect(body.snapshot).to.be.a('string')
expect(body.mergeEnvVars).to.equal(true)
})
})
})
Expand Down
8 changes: 8 additions & 0 deletions test/unit/forge/auditLog/project_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,14 @@ describe('Audit Log > Project', async function () {
logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid)
})

it('Records whether env vars were merged when rolling back a snapshot', async function () {
await projectLogger.project.snapshot.rolledBack(ACTIONED_BY, null, PROJECT, SNAPSHOT, { mergeEnvVars: true })
const logEntry = await getLog()
logEntry.should.have.property('event', 'project.snapshot.rolled-back')
logEntry.body.should.only.have.keys('project', 'snapshot', 'info')
logEntry.body.info.should.eql({ mergeEnvVars: true })
})

it('Provides a logger for deleteing a snapshot of a project', async function () {
await projectLogger.project.snapshot.deleted(ACTIONED_BY, null, PROJECT, SNAPSHOT)
// check log stored
Expand Down
64 changes: 64 additions & 0 deletions test/unit/forge/routes/api/projectSnapshots_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,70 @@ describe('Project Snapshots API', function () {
rolledBackSettings.env.should.have.property('two', 'b')
})

it('keeps the instance env vars when rolling back with mergeEnvVars', async function () {
const setEnv = async (env) => {
const result = await addFlowsToProject(app,
TestObjects.project1.id,
TestObjects.tokens.project,
TestObjects.tokens.alice,
[{ id: 'node1' }],
{ testCreds: 'abc' },
'key1',
{ env }
)
result.updateProjectSettingsResponse.statusCode.should.equal(200)
}
const getEnv = async () => {
const response = await app.inject({
method: 'GET',
url: `/api/v1/projects/${app.project.id}/settings`,
headers: { authorization: `Bearer ${TestObjects.tokens.project}` }
})
return response.json().env
}
const rollback = (snapshotId, mergeEnvVars) => app.inject({
method: 'POST',
url: `/api/v1/projects/${app.project.id}/actions/rollback`,
payload: { snapshot: snapshotId, mergeEnvVars },
cookies: { sid: TestObjects.tokens.alice }
})

await setEnv([
{ name: 'one', value: 'a' },
{ name: 'two', value: 'b' }
])
const snapshot = (await createSnapshot(TestObjects.project1.id, 'merge-env-snapshot', TestObjects.tokens.alice)).json()

// 'one' changed and 'three' added after the snapshot, 'two' removed
await setEnv([
{ name: 'one', value: 'a2' },
{ name: 'three', value: 'c' }
])

const mergeResponse = await rollback(snapshot.id, true)
mergeResponse.statusCode.should.equal(200)
const mergeLog = await app.db.models.AuditLog.findOne({ where: { event: 'project.snapshot.rolled-back', entityId: app.project.id }, order: [['id', 'DESC']] })
JSON.parse(mergeLog.body).info.should.eql({ mergeEnvVars: true })
const merged = await getEnv()
merged.should.have.property('one', 'a2') // existing value kept
merged.should.have.property('two', 'b') // added from snapshot
merged.should.have.property('three', 'c') // existing key kept

await setEnv([
{ name: 'one', value: 'a2' },
{ name: 'three', value: 'c' }
])

const replaceResponse = await rollback(snapshot.id, false)
replaceResponse.statusCode.should.equal(200)
const replaceLog = await app.db.models.AuditLog.findOne({ where: { event: 'project.snapshot.rolled-back', entityId: app.project.id }, order: [['id', 'DESC']] })
JSON.parse(replaceLog.body).info.should.eql({ mergeEnvVars: false })
const replaced = await getEnv()
replaced.should.have.property('one', 'a')
replaced.should.have.property('two', 'b')
replaced.should.not.have.property('three')
})

it('holds a restarting mask through the bounce when rolling back a running instance', async function () {
const snapResponse = await createSnapshot(app.project.id, 'rollback-mask-snapshot', TestObjects.tokens.alice)
snapResponse.statusCode.should.equal(200)
Expand Down
Loading