Skip to content

Commit e1afdf1

Browse files
add rollback support to installation steps
Introduce rollback functionality to the installation process, enabling the reversal of completed steps upon failure. Extend «InstallationAIAssistantStep» with a new «rollback» method and update «InstallationProgressManager» to manage rollback execution. Add rollback handling for agent, connector, and model creation steps, ensuring created resources are cleaned up if subsequent steps fail. Enhance integration tests to validate rollback behavior and update the «InstallationResult» structure to include «rollbackErrors» for improved error reporting.
1 parent 0ce7d61 commit e1afdf1

20 files changed

+326
-15
lines changed

public/dashboard-assistant/modules/agent/application/ports/__mocks__/agent-repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { AgentRepository } from '../agent-repository';
88
export function createAgentRepositoryMock(): jest.Mocked<AgentRepository> {
99
return {
1010
create: jest.fn(),
11+
delete: jest.fn(),
1112
execute: jest.fn(),
1213
getActive: jest.fn(),
1314
register: jest.fn(),

public/dashboard-assistant/modules/agent/application/ports/agent-repository.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { CreateRepository } from '../../../common/domain/entities/repository';
6+
import { CreateRepository, DeleteRepository } from '../../../common/domain/entities/repository';
77
import { Agent } from '../../domain/entities/agent';
88
import { CreateAgentDto } from '../dtos/create-agent-dto';
99

10-
export interface AgentRepository extends CreateRepository<Agent, CreateAgentDto> {
10+
export interface AgentRepository
11+
extends CreateRepository<Agent, CreateAgentDto>,
12+
DeleteRepository {
1113
execute(id: string, parameters: any): Promise<any>;
1214
getActive(): Promise<string | undefined>;
1315
register(agentId: string): Promise<void>;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright Wazuh Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import type { AgentRepository } from '../ports/agent-repository';
7+
8+
export const deleteAgentUseCase = (agentRepository: AgentRepository) => async (
9+
agentId: string
10+
): Promise<void> => {
11+
await agentRepository.delete(agentId);
12+
};
13+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright Wazuh Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import type { ConnectorRepository } from '../ports/connector-repository';
7+
8+
export const deleteConnectorUseCase = (
9+
connectorRepository: ConnectorRepository
10+
) => async (connectorId: string): Promise<void> => {
11+
await connectorRepository.delete(connectorId);
12+
};
13+

public/dashboard-assistant/modules/installation-manager/domain/entities/installation-ai-assistant-step.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,9 @@ export abstract class InstallationAIAssistantStep {
2222
): Promise<void>;
2323
abstract getSuccessMessage(): string;
2424
abstract getFailureMessage(): string;
25+
abstract rollback(
26+
request: InstallAIDashboardAssistantDto,
27+
context: InstallationContext,
28+
error: Error
29+
): Promise<void>;
2530
}

public/dashboard-assistant/modules/installation-manager/domain/entities/installation-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export class InstallationContext {
2121
return this.context.has(key);
2222
}
2323

24+
public delete(key: string): void {
25+
this.context.delete(key);
26+
}
27+
2428
public clear(): void {
2529
this.context.clear();
2630
}

public/dashboard-assistant/modules/installation-manager/domain/entities/installation-progress-manager.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import { InstallationProgressManager } from './installation-progress-manager';
77
import { InstallationAIAssistantStep } from './installation-ai-assistant-step';
8-
import { ExecutionState, StepResultState } from '../enums';
8+
import { ExecutionState } from '../enums';
9+
import type { InstallAIDashboardAssistantDto } from '../types/install-ai-dashboard-assistant-dto';
10+
import { InstallationContext } from './installation-context';
911

1012
class TestStep extends InstallationAIAssistantStep {
1113
constructor(
@@ -24,9 +26,24 @@ class TestStep extends InstallationAIAssistantStep {
2426
getFailureMessage(): string {
2527
return this.failureMsg;
2628
}
29+
async rollback(
30+
_request: InstallAIDashboardAssistantDto,
31+
_context: InstallationContext,
32+
_error: Error
33+
): Promise<void> {
34+
// no-op for tests
35+
}
2736
}
2837

2938
describe('InstallationProgressManager', () => {
39+
const request: InstallAIDashboardAssistantDto = {
40+
selected_provider: 'test-provider',
41+
model_id: 'test-model',
42+
api_url: 'https://example.org',
43+
api_key: 'secret',
44+
};
45+
const createContext = () => new InstallationContext();
46+
3047
it('initializes progress with provided steps in PENDING state', () => {
3148
const steps = [new TestStep('Step 1'), new TestStep('Step 2')];
3249
const mgr = new InstallationProgressManager(steps);
@@ -42,7 +59,7 @@ describe('InstallationProgressManager', () => {
4259
const onProgressChange = jest.fn();
4360
const mgr = new InstallationProgressManager(steps, onProgressChange);
4461

45-
await mgr.runStep(steps[0], async () => {
62+
await mgr.runStep(steps[0], request, createContext(), async () => {
4663
/* success */
4764
});
4865

@@ -58,7 +75,7 @@ describe('InstallationProgressManager', () => {
5875
const mgr = new InstallationProgressManager(steps);
5976

6077
await expect(
61-
mgr.runStep(steps[0], async () => {
78+
mgr.runStep(steps[0], request, createContext(), async () => {
6279
throw new Error('boom');
6380
})
6481
).rejects.toThrow('boom');
@@ -76,9 +93,14 @@ describe('InstallationProgressManager', () => {
7693
const mgr = new InstallationProgressManager(steps);
7794

7895
let resolveExec!: () => void;
79-
const running = mgr.runStep(steps[0], () => new Promise<void>((res) => (resolveExec = res)));
96+
const running = mgr.runStep(
97+
steps[0],
98+
request,
99+
createContext(),
100+
() => new Promise<void>((res) => (resolveExec = res))
101+
);
80102

81-
await expect(mgr.runStep(steps[0], async () => {})).rejects.toThrow();
103+
await expect(mgr.runStep(steps[0], request, createContext(), async () => {})).rejects.toThrow();
82104

83105
resolveExec();
84106
await running;
@@ -88,13 +110,13 @@ describe('InstallationProgressManager', () => {
88110
const steps = [new TestStep('S1'), new TestStep('S2')];
89111
const mgr = new InstallationProgressManager(steps);
90112

91-
await mgr.runStep(steps[0], async () => {});
92-
await mgr.runStep(steps[1], async () => {});
113+
await mgr.runStep(steps[0], request, createContext(), async () => {});
114+
await mgr.runStep(steps[1], request, createContext(), async () => {});
93115

94116
const p = mgr.getProgress();
95117

96118
expect(p.isFinished()).toBe(true);
97-
await expect(mgr.runStep(steps[0], async () => {})).rejects.toThrow();
119+
await expect(mgr.runStep(steps[0], request, createContext(), async () => {})).rejects.toThrow();
98120
});
99121

100122
it('reset returns all steps to PENDING and clears result/message/error', async () => {
@@ -103,9 +125,9 @@ describe('InstallationProgressManager', () => {
103125
const mgr = new InstallationProgressManager(steps, onProgressChange);
104126

105127
// Make one success and one failure
106-
await mgr.runStep(steps[0], async () => {});
128+
await mgr.runStep(steps[0], request, createContext(), async () => {});
107129
await expect(
108-
mgr.runStep(steps[1], async () => {
130+
mgr.runStep(steps[1], request, createContext(), async () => {
109131
throw new Error('x');
110132
})
111133
).rejects.toThrow('x');

public/dashboard-assistant/modules/installation-manager/domain/entities/installation-progress-manager.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@
66
import { ExecutionState, StepResultState } from '../enums';
77
import { InstallationAIAssistantStep } from './installation-ai-assistant-step';
88
import { InstallationProgress } from './installation-progress';
9+
import type { InstallAIDashboardAssistantDto } from '../types/install-ai-dashboard-assistant-dto';
10+
import type { InstallationContext } from './installation-context';
11+
12+
type RollbackError = {
13+
step: string;
14+
message: string;
15+
};
916

1017
export class InstallationProgressManager {
1118
private readonly progress: InstallationProgress;
1219
// Prevent concurrent executions
1320
private inProgress = false;
21+
private completedSteps: InstallationAIAssistantStep[] = [];
22+
private rollbackErrors: RollbackError[] = [];
1423

1524
constructor(
1625
steps: InstallationAIAssistantStep[],
@@ -39,6 +48,8 @@ export class InstallationProgressManager {
3948

4049
public async runStep(
4150
step: InstallationAIAssistantStep,
51+
request: InstallAIDashboardAssistantDto,
52+
context: InstallationContext,
4253
executor: () => Promise<void>
4354
): Promise<void> {
4455
if (this.inProgress) {
@@ -51,18 +62,25 @@ export class InstallationProgressManager {
5162

5263
this.inProgress = true;
5364
this.progress.startStep(i);
65+
this.rollbackErrors = [];
5466
try {
5567
await executor();
68+
this.completedSteps.push(step);
5669
this.succeedStep(i, step);
5770
} catch (err) {
5871
const error = err instanceof Error ? err : new Error(String(err));
72+
await this.rollbackSteps(step, request, context, error);
5973
this.failStep(i, step, error);
6074
throw error;
6175
} finally {
6276
this.inProgress = false;
6377
}
6478
}
6579

80+
public getRollbackErrors(): RollbackError[] | undefined {
81+
return this.rollbackErrors.length > 0 ? [...this.rollbackErrors] : undefined;
82+
}
83+
6684
private succeedStep(stepIndex: number, step: InstallationAIAssistantStep): void {
6785
this.progress.completeStep(stepIndex, StepResultState.SUCCESS, step.getSuccessMessage());
6886
}
@@ -76,4 +94,34 @@ export class InstallationProgressManager {
7694
this.onProgressChange(this.getProgress());
7795
}
7896
}
97+
98+
private async rollbackSteps(
99+
failedStep: InstallationAIAssistantStep,
100+
request: InstallAIDashboardAssistantDto,
101+
context: InstallationContext,
102+
failure: Error
103+
): Promise<void> {
104+
const stepsToRollback = [...this.completedSteps].reverse();
105+
106+
await this.invokeRollback(failedStep, request, context, failure);
107+
for (const step of stepsToRollback) {
108+
await this.invokeRollback(step, request, context, failure);
109+
}
110+
111+
this.completedSteps = [];
112+
}
113+
114+
private async invokeRollback(
115+
step: InstallationAIAssistantStep,
116+
request: InstallAIDashboardAssistantDto,
117+
context: InstallationContext,
118+
failure: Error
119+
): Promise<void> {
120+
try {
121+
await step.rollback(request, context, failure);
122+
} catch (error) {
123+
const normalizedError = error instanceof Error ? error : new Error(String(error));
124+
this.rollbackErrors.push({ step: step.getName(), message: normalizedError.message });
125+
}
126+
}
79127
}

public/dashboard-assistant/modules/installation-manager/domain/types/installation-result.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@ export interface InstallationResult {
1818
[key: string]: any;
1919
};
2020
errors?: InstallationError[];
21+
rollbackErrors?: Array<{
22+
step: string;
23+
message: string;
24+
}>;
2125
}

public/dashboard-assistant/modules/installation-manager/infrastructure/installation-manager.int.test.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ describe('InstallationManager (integration)', () => {
3838
validateModelConnection: jest.fn().mockResolvedValue(true),
3939
createAgent: jest.fn().mockResolvedValue({ id: 'agent-1' }),
4040
useAgent: jest.fn().mockResolvedValue(undefined),
41+
deleteConnector: jest.fn(),
42+
deleteModel: jest.fn(),
43+
deleteAgent: jest.fn(),
4144
};
4245

4346
const progressUpdates: unknown[] = [];
@@ -70,14 +73,97 @@ describe('InstallationManager (integration)', () => {
7073
validateModelConnection: jest.fn(),
7174
createAgent: jest.fn(),
7275
useAgent: jest.fn(),
76+
deleteConnector: jest.fn().mockResolvedValue(undefined),
77+
deleteModel: jest.fn(),
78+
deleteAgent: jest.fn(),
7379
};
7480

7581
const manager = new InstallationManager();
7682
const result = await manager.execute(request);
7783
expect(result.success).toBe(false);
7884
expect(result.errors).toBeDefined();
7985
expect(result.errors![0]).toMatchObject({ step: 'Create Model' });
80-
// Data should contain only the connectorId
81-
expect(result.data).toEqual({ connectorId: 'conn-1' });
86+
// Data should not contain identifiers for successfully rolled-back steps
87+
expect(result.data).toEqual({});
88+
expect(result.rollbackErrors).toBeUndefined();
89+
expect(
90+
((global as unknown) as {
91+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
92+
}).__mockUseCases!.deleteConnector
93+
).toHaveBeenCalledWith('conn-1');
94+
expect(
95+
((global as unknown) as {
96+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
97+
}).__mockUseCases!.deleteModel
98+
).not.toHaveBeenCalled();
99+
expect(
100+
((global as unknown) as {
101+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
102+
}).__mockUseCases!.deleteAgent
103+
).not.toHaveBeenCalled();
104+
});
105+
106+
it('rolls back created resources when a later step fails', async () => {
107+
((global as unknown) as {
108+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
109+
}).__mockUseCases = {
110+
persistMlCommonsSettings: jest.fn().mockResolvedValue(undefined),
111+
createConnector: jest.fn().mockResolvedValue({ id: 'conn-1' }),
112+
createModel: jest.fn().mockResolvedValue({ id: 'model-1' }),
113+
validateModelConnection: jest.fn().mockResolvedValue(true),
114+
createAgent: jest.fn().mockResolvedValue({ id: 'agent-1' }),
115+
useAgent: jest.fn().mockRejectedValue(new Error('register failed')),
116+
deleteConnector: jest.fn().mockResolvedValue(undefined),
117+
deleteModel: jest.fn().mockResolvedValue(undefined),
118+
deleteAgent: jest.fn().mockResolvedValue(undefined),
119+
};
120+
121+
const manager = new InstallationManager();
122+
const result = await manager.execute(request);
123+
124+
expect(result.success).toBe(false);
125+
expect(result.errors![0]).toMatchObject({ step: 'Register Agent' });
126+
expect(result.data).toEqual({});
127+
expect(result.rollbackErrors).toBeUndefined();
128+
129+
const mockUseCases = ((global as unknown) as {
130+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
131+
}).__mockUseCases!;
132+
133+
expect(mockUseCases.deleteAgent).toHaveBeenCalledWith('agent-1');
134+
expect(mockUseCases.deleteModel).toHaveBeenCalledWith('model-1');
135+
expect(mockUseCases.deleteConnector).toHaveBeenCalledWith('conn-1');
136+
});
137+
138+
it('rolls back created resources when model connection validation fails', async () => {
139+
((global as unknown) as {
140+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
141+
}).__mockUseCases = {
142+
persistMlCommonsSettings: jest.fn().mockResolvedValue(undefined),
143+
createConnector: jest.fn().mockResolvedValue({ id: 'conn-1' }),
144+
createModel: jest.fn().mockResolvedValue({ id: 'model-1' }),
145+
validateModelConnection: jest.fn().mockRejectedValue(new Error('validation failed')),
146+
createAgent: jest.fn(),
147+
useAgent: jest.fn(),
148+
deleteConnector: jest.fn().mockResolvedValue(undefined),
149+
deleteModel: jest.fn().mockResolvedValue(undefined),
150+
deleteAgent: jest.fn().mockResolvedValue(undefined),
151+
};
152+
153+
const manager = new InstallationManager();
154+
const result = await manager.execute(request);
155+
156+
expect(result.success).toBe(false);
157+
expect(result.errors![0]).toMatchObject({ step: 'Test Model Connection' });
158+
expect(result.data).toEqual({});
159+
expect(result.rollbackErrors).toBeUndefined();
160+
161+
const mockUseCases = ((global as unknown) as {
162+
__mockUseCases: import('../../../services/__mocks__').MockUseCases;
163+
}).__mockUseCases!;
164+
165+
expect(mockUseCases.deleteModel).toHaveBeenCalledWith('model-1');
166+
expect(mockUseCases.deleteConnector).toHaveBeenCalledWith('conn-1');
167+
expect(mockUseCases.deleteAgent).not.toHaveBeenCalled();
82168
});
83169
});

0 commit comments

Comments
 (0)