Skip to content

Commit 9755e6a

Browse files
fix(workflows): harden deploy rollback recovery
1 parent 4a9d7c8 commit 9755e6a

File tree

4 files changed

+298
-12
lines changed

4 files changed

+298
-12
lines changed

apps/sim/app/api/workflows/[id]/deploy/route.test.ts

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const {
9+
mockActivateWorkflowVersionById,
910
mockCleanupWebhooksForWorkflow,
11+
mockCleanupDeploymentVersion,
1012
mockRecordAudit,
1113
mockDbLimit,
1214
mockDbOrderBy,
1315
mockDbFrom,
16+
mockDbInnerJoin,
1417
mockDbSelect,
1518
mockDbSet,
1619
mockDbUpdate,
@@ -19,18 +22,24 @@ const {
1922
mockDeployWorkflow,
2023
mockLoadWorkflowFromNormalizedTables,
2124
mockRemoveMcpToolsForWorkflow,
25+
mockReactivateWorkflowVersionForRollback,
26+
mockRestorePreviousVersionWebhooks,
2227
mockSaveTriggerWebhooksForDeploy,
2328
mockSyncMcpToolsForWorkflow,
29+
mockDeleteDeploymentVersionById,
2430
mockUndeployWorkflow,
2531
mockValidatePublicApiAllowed,
2632
mockValidateWorkflowAccess,
2733
mockValidateWorkflowPermissions,
2834
} = vi.hoisted(() => ({
35+
mockActivateWorkflowVersionById: vi.fn(),
2936
mockCleanupWebhooksForWorkflow: vi.fn(),
37+
mockCleanupDeploymentVersion: vi.fn(),
3038
mockRecordAudit: vi.fn(),
3139
mockDbLimit: vi.fn(),
3240
mockDbOrderBy: vi.fn(),
3341
mockDbFrom: vi.fn(),
42+
mockDbInnerJoin: vi.fn(),
3443
mockDbSelect: vi.fn(),
3544
mockDbSet: vi.fn(),
3645
mockDbUpdate: vi.fn(),
@@ -39,8 +48,11 @@ const {
3948
mockDeployWorkflow: vi.fn(),
4049
mockLoadWorkflowFromNormalizedTables: vi.fn(),
4150
mockRemoveMcpToolsForWorkflow: vi.fn(),
51+
mockReactivateWorkflowVersionForRollback: vi.fn(),
52+
mockRestorePreviousVersionWebhooks: vi.fn(),
4253
mockSaveTriggerWebhooksForDeploy: vi.fn(),
4354
mockSyncMcpToolsForWorkflow: vi.fn(),
55+
mockDeleteDeploymentVersionById: vi.fn(),
4456
mockUndeployWorkflow: vi.fn(),
4557
mockValidatePublicApiAllowed: vi.fn(),
4658
mockValidateWorkflowAccess: vi.fn(),
@@ -65,7 +77,7 @@ vi.mock('@/lib/core/utils/request', () => ({
6577

6678
vi.mock('@sim/db', () => ({
6779
db: { select: mockDbSelect, update: mockDbUpdate },
68-
workflow: { variables: 'variables', id: 'id' },
80+
workflow: { variables: 'variables', id: 'id', deployedAt: 'deployedAt' },
6981
workflowDeploymentVersion: {
7082
state: 'state',
7183
workflowId: 'workflowId',
@@ -88,23 +100,28 @@ vi.mock('drizzle-orm', async (importOriginal) => {
88100
vi.mock('@/lib/workflows/persistence/utils', () => ({
89101
loadWorkflowFromNormalizedTables: (...args: unknown[]) =>
90102
mockLoadWorkflowFromNormalizedTables(...args),
103+
deleteDeploymentVersionById: (...args: unknown[]) => mockDeleteDeploymentVersionById(...args),
91104
deployWorkflow: (...args: unknown[]) => mockDeployWorkflow(...args),
105+
reactivateWorkflowVersionForRollback: (...args: unknown[]) =>
106+
mockReactivateWorkflowVersionForRollback(...args),
92107
undeployWorkflow: (...args: unknown[]) => mockUndeployWorkflow(...args),
108+
activateWorkflowVersionById: (...args: unknown[]) => mockActivateWorkflowVersionById(...args),
93109
}))
94110

95111
vi.mock('@/lib/workflows/comparison', () => ({
96112
hasWorkflowChanged: vi.fn().mockReturnValue(false),
97113
}))
98114

99115
vi.mock('@/lib/workflows/schedules', () => ({
100-
cleanupDeploymentVersion: vi.fn(),
116+
cleanupDeploymentVersion: (...args: unknown[]) => mockCleanupDeploymentVersion(...args),
101117
createSchedulesForDeploy: (...args: unknown[]) => mockCreateSchedulesForDeploy(...args),
102118
validateWorkflowSchedules: vi.fn().mockReturnValue({ isValid: true }),
103119
}))
104120

105121
vi.mock('@/lib/webhooks/deploy', () => ({
106122
cleanupWebhooksForWorkflow: (...args: unknown[]) => mockCleanupWebhooksForWorkflow(...args),
107-
restorePreviousVersionWebhooks: vi.fn(),
123+
restorePreviousVersionWebhooks: (...args: unknown[]) =>
124+
mockRestorePreviousVersionWebhooks(...args),
108125
saveTriggerWebhooksForDeploy: (...args: unknown[]) => mockSaveTriggerWebhooksForDeploy(...args),
109126
}))
110127

@@ -130,20 +147,26 @@ describe('Workflow deploy route', () => {
130147
beforeEach(() => {
131148
vi.clearAllMocks()
132149
mockDbSelect.mockReturnValue({ from: mockDbFrom })
133-
mockDbFrom.mockReturnValue({ where: mockDbWhere })
150+
mockDbFrom.mockReturnValue({ where: mockDbWhere, innerJoin: mockDbInnerJoin })
151+
mockDbInnerJoin.mockReturnValue({ where: mockDbWhere })
134152
mockDbWhere.mockReturnValue({ limit: mockDbLimit, orderBy: mockDbOrderBy })
135153
mockDbOrderBy.mockReturnValue({ limit: mockDbLimit })
136154
mockDbLimit.mockResolvedValue([])
137155
mockDbUpdate.mockReturnValue({ set: mockDbSet })
138156
mockDbSet.mockReturnValue({ where: mockDbWhere })
139157
mockCleanupWebhooksForWorkflow.mockResolvedValue(undefined)
140158
mockCreateSchedulesForDeploy.mockResolvedValue({ success: true })
159+
mockCleanupDeploymentVersion.mockResolvedValue(undefined)
160+
mockDeleteDeploymentVersionById.mockResolvedValue({ success: true })
141161
mockLoadWorkflowFromNormalizedTables.mockResolvedValue({
142162
blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } },
143163
edges: [],
144164
loops: {},
145165
parallels: {},
146166
})
167+
mockActivateWorkflowVersionById.mockResolvedValue({ success: true })
168+
mockReactivateWorkflowVersionForRollback.mockResolvedValue({ success: true })
169+
mockRestorePreviousVersionWebhooks.mockResolvedValue(undefined)
147170
mockSaveTriggerWebhooksForDeploy.mockResolvedValue({ success: true, warnings: [] })
148171
mockRemoveMcpToolsForWorkflow.mockResolvedValue(undefined)
149172
mockSyncMcpToolsForWorkflow.mockResolvedValue(undefined)
@@ -221,6 +244,94 @@ describe('Workflow deploy route', () => {
221244
expect(mockRecordAudit).toHaveBeenCalled()
222245
})
223246

247+
it('preserves prior deployedAt when failed redeploy rolls back', async () => {
248+
mockDbLimit.mockResolvedValue([
249+
{ id: 'prev-1', deployedAt: new Date('2024-01-01T00:00:00.000Z') },
250+
])
251+
mockValidateWorkflowAccess.mockResolvedValue({
252+
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
253+
auth: {
254+
success: true,
255+
userId: 'api-user',
256+
userName: 'API Key Actor',
257+
userEmail: 'api@example.com',
258+
authType: 'api_key',
259+
},
260+
})
261+
mockDeployWorkflow.mockResolvedValue({
262+
success: true,
263+
deployedAt: '2024-02-01T00:00:00Z',
264+
deploymentVersionId: 'dep-failed',
265+
})
266+
mockSaveTriggerWebhooksForDeploy.mockResolvedValue({
267+
success: false,
268+
error: { message: 'Failed to save trigger configuration', status: 500 },
269+
})
270+
271+
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', {
272+
method: 'POST',
273+
headers: { 'x-api-key': 'test-key' },
274+
})
275+
const response = await POST(req, { params: Promise.resolve({ id: 'wf-1' }) })
276+
277+
expect(response.status).toBe(500)
278+
expect(mockReactivateWorkflowVersionForRollback).toHaveBeenCalledWith({
279+
workflowId: 'wf-1',
280+
deploymentVersionId: 'prev-1',
281+
deployedAt: new Date('2024-01-01T00:00:00.000Z'),
282+
})
283+
expect(mockActivateWorkflowVersionById).not.toHaveBeenCalled()
284+
expect(mockRestorePreviousVersionWebhooks).toHaveBeenCalledWith(
285+
expect.objectContaining({
286+
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
287+
previousVersionId: 'prev-1',
288+
requestId: 'req-123',
289+
userId: 'api-user',
290+
})
291+
)
292+
expect(mockDeleteDeploymentVersionById).toHaveBeenCalledWith({
293+
workflowId: 'wf-1',
294+
deploymentVersionId: 'dep-failed',
295+
})
296+
})
297+
298+
it('deletes failed created deployment version when first deploy rollback runs', async () => {
299+
mockValidateWorkflowAccess.mockResolvedValue({
300+
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
301+
auth: {
302+
success: true,
303+
userId: 'api-user',
304+
userName: 'API Key Actor',
305+
userEmail: 'api@example.com',
306+
authType: 'api_key',
307+
},
308+
})
309+
mockDeployWorkflow.mockResolvedValue({
310+
success: true,
311+
deployedAt: '2024-02-01T00:00:00Z',
312+
deploymentVersionId: 'dep-failed',
313+
})
314+
mockSaveTriggerWebhooksForDeploy.mockResolvedValue({
315+
success: false,
316+
error: { message: 'Failed to save trigger configuration', status: 500 },
317+
})
318+
mockDbLimit.mockResolvedValue([])
319+
mockUndeployWorkflow.mockResolvedValue({ success: true })
320+
321+
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', {
322+
method: 'POST',
323+
headers: { 'x-api-key': 'test-key' },
324+
})
325+
const response = await POST(req, { params: Promise.resolve({ id: 'wf-1' }) })
326+
327+
expect(response.status).toBe(500)
328+
expect(mockDeleteDeploymentVersionById).toHaveBeenCalledWith({
329+
workflowId: 'wf-1',
330+
deploymentVersionId: 'dep-failed',
331+
})
332+
expect(mockUndeployWorkflow).toHaveBeenCalledWith({ workflowId: 'wf-1' })
333+
})
334+
224335
it('allows API-key auth for undeploy using hybrid auth userId', async () => {
225336
mockValidateWorkflowAccess.mockResolvedValue({
226337
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },

apps/sim/app/api/workflows/[id]/deploy/route.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
} from '@/lib/webhooks/deploy'
1414
import {
1515
activateWorkflowVersionById,
16+
deleteDeploymentVersionById,
1617
deployWorkflow,
1718
loadWorkflowFromNormalizedTables,
19+
reactivateWorkflowVersionForRollback,
1820
undeployWorkflow,
1921
} from '@/lib/workflows/persistence/utils'
2022
import {
@@ -115,8 +117,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
115117
}
116118

117119
const [currentActiveVersion] = await db
118-
.select({ id: workflowDeploymentVersion.id })
120+
.select({ id: workflowDeploymentVersion.id, deployedAt: workflow.deployedAt })
119121
.from(workflowDeploymentVersion)
122+
.innerJoin(workflow, eq(workflowDeploymentVersion.workflowId, workflow.id))
120123
.where(
121124
and(
122125
eq(workflowDeploymentVersion.workflowId, id),
@@ -125,8 +128,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
125128
)
126129
.limit(1)
127130
const previousVersionId = currentActiveVersion?.id
131+
const previousDeployedAt = currentActiveVersion?.deployedAt ?? null
128132

129-
const rollbackDeployment = async () => {
133+
const rollbackDeployment = async (failedDeploymentVersionId?: string) => {
130134
if (previousVersionId) {
131135
await restorePreviousVersionWebhooks({
132136
request,
@@ -135,15 +139,34 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
135139
previousVersionId,
136140
requestId,
137141
})
138-
const reactivateResult = await activateWorkflowVersionById({
139-
workflowId: id,
140-
deploymentVersionId: previousVersionId,
141-
})
142+
const reactivateResult = previousDeployedAt
143+
? await reactivateWorkflowVersionForRollback({
144+
workflowId: id,
145+
deploymentVersionId: previousVersionId,
146+
deployedAt: previousDeployedAt,
147+
})
148+
: await activateWorkflowVersionById({
149+
workflowId: id,
150+
deploymentVersionId: previousVersionId,
151+
})
142152
if (reactivateResult.success) {
153+
if (failedDeploymentVersionId) {
154+
await deleteDeploymentVersionById({
155+
workflowId: id,
156+
deploymentVersionId: failedDeploymentVersionId,
157+
})
158+
}
143159
return
144160
}
145161
}
146162

163+
if (failedDeploymentVersionId) {
164+
await deleteDeploymentVersionById({
165+
workflowId: id,
166+
deploymentVersionId: failedDeploymentVersionId,
167+
})
168+
}
169+
147170
await undeployWorkflow({ workflowId: id })
148171
}
149172

@@ -183,7 +206,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
183206
requestId,
184207
deploymentVersionId,
185208
})
186-
await rollbackDeployment()
209+
await rollbackDeployment(deploymentVersionId)
187210
return createErrorResponse(
188211
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
189212
triggerSaveResult.error?.status || 500
@@ -207,7 +230,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
207230
requestId,
208231
deploymentVersionId,
209232
})
210-
await rollbackDeployment()
233+
await rollbackDeployment(deploymentVersionId)
211234
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
212235
}
213236
if (scheduleResult.scheduleId) {

apps/sim/lib/workflows/persistence/utils.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,55 @@ describe('Database Helpers', () => {
12181218
})
12191219
})
12201220

1221+
describe('rollback deployment helpers', () => {
1222+
it('reactivates a deployment version while preserving deployedAt during rollback', async () => {
1223+
const preservedDeployedAt = new Date('2024-01-01T00:00:00.000Z')
1224+
const txWhere = vi.fn().mockResolvedValue(undefined)
1225+
const txSet = vi.fn().mockReturnValue({ where: txWhere })
1226+
const tx = {
1227+
update: vi.fn().mockReturnValue({ set: txSet }),
1228+
}
1229+
1230+
mockDb.select.mockReturnValue({
1231+
from: vi.fn().mockReturnValue({
1232+
where: vi.fn().mockReturnValue({
1233+
limit: vi.fn().mockResolvedValue([{ id: 'dep-1', state: { blocks: {} } }]),
1234+
}),
1235+
}),
1236+
})
1237+
mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx))
1238+
1239+
const result = await dbHelpers.reactivateWorkflowVersionForRollback({
1240+
workflowId: mockWorkflowId,
1241+
deploymentVersionId: 'dep-1',
1242+
deployedAt: preservedDeployedAt,
1243+
})
1244+
1245+
expect(result).toEqual({
1246+
success: true,
1247+
deployedAt: preservedDeployedAt,
1248+
state: { blocks: {} },
1249+
})
1250+
expect(mockDb.transaction).toHaveBeenCalledTimes(1)
1251+
expect(tx.update).toHaveBeenCalledTimes(3)
1252+
expect(txSet).toHaveBeenCalledWith({ isDeployed: true, deployedAt: preservedDeployedAt })
1253+
})
1254+
1255+
it('deletes a failed deployment version row by id', async () => {
1256+
const deleteWhere = vi.fn().mockResolvedValue(undefined)
1257+
mockDb.delete.mockReturnValue({ where: deleteWhere })
1258+
1259+
const result = await dbHelpers.deleteDeploymentVersionById({
1260+
workflowId: mockWorkflowId,
1261+
deploymentVersionId: 'dep-failed',
1262+
})
1263+
1264+
expect(result).toEqual({ success: true })
1265+
expect(mockDb.delete).toHaveBeenCalled()
1266+
expect(deleteWhere).toHaveBeenCalledTimes(1)
1267+
})
1268+
})
1269+
12211270
describe('migrateAgentBlocksToMessagesFormat', () => {
12221271
it('should migrate agent block with both systemPrompt and userPrompt', () => {
12231272
const blocks = {

0 commit comments

Comments
 (0)