mcp-eregistrations-bpa 0.8.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-eregistrations-bpa might be problematic. Click here for more details.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Rollback manager for BPA operations.
|
|
2
|
+
|
|
3
|
+
This module provides the RollbackManager class for executing rollback operations
|
|
4
|
+
on BPA objects. It handles endpoint resolution, state retrieval, and API calls
|
|
5
|
+
to restore objects to their previous state.
|
|
6
|
+
|
|
7
|
+
Rollback strategies by operation type:
|
|
8
|
+
- create: DELETE the created object
|
|
9
|
+
- update: PUT with previous_state values to restore
|
|
10
|
+
- delete: POST to recreate object with previous_state
|
|
11
|
+
- link: Reverse the link operation (unlink)
|
|
12
|
+
- unlink: Reverse the unlink operation (link)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
23
|
+
from mcp_eregistrations_bpa.bpa_client.errors import BPAClientError
|
|
24
|
+
from mcp_eregistrations_bpa.db import get_connection
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"RollbackManager",
|
|
28
|
+
"RollbackError",
|
|
29
|
+
"RollbackNotPossibleError",
|
|
30
|
+
"ROLLBACK_ENDPOINTS",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RollbackError(Exception):
|
|
35
|
+
"""Base exception for rollback operations."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RollbackNotPossibleError(RollbackError):
|
|
41
|
+
"""Raised when rollback cannot be performed for a given audit entry."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Mapping: object_type -> (delete_endpoint, update_endpoint, create_endpoint)
|
|
47
|
+
# Endpoints use {id}, {service_id}, {registration_id} placeholders for substitution
|
|
48
|
+
# IMPORTANT: These endpoints must match the actual BPA API specification
|
|
49
|
+
# See: _bmad-output/implementation-artifacts/bpa-api-reference.md
|
|
50
|
+
ROLLBACK_ENDPOINTS: dict[str, tuple[str | None, str | None, str | None]] = {
|
|
51
|
+
# Service: PUT /service with ID in body, DELETE /service/{id}
|
|
52
|
+
"service": ("/service/{id}", "/service", "/service"),
|
|
53
|
+
# Registration: No PUT endpoint exists in BPA API, create via service
|
|
54
|
+
"registration": (
|
|
55
|
+
"/registration/{id}",
|
|
56
|
+
None, # No PUT /registration endpoint in BPA API
|
|
57
|
+
"/service/{service_id}/registration",
|
|
58
|
+
),
|
|
59
|
+
# Determinants: Scoped to service for both update and create
|
|
60
|
+
"textdeterminant": (
|
|
61
|
+
None,
|
|
62
|
+
"/service/{service_id}/textdeterminant",
|
|
63
|
+
"/service/{service_id}/textdeterminant",
|
|
64
|
+
),
|
|
65
|
+
"selectdeterminant": (
|
|
66
|
+
None,
|
|
67
|
+
None,
|
|
68
|
+
"/service/{service_id}/selectdeterminant",
|
|
69
|
+
),
|
|
70
|
+
# Bot: No DELETE endpoint, PUT /bot with ID in body
|
|
71
|
+
"bot": (
|
|
72
|
+
None, # No DELETE /bot endpoint in BPA API
|
|
73
|
+
"/bot",
|
|
74
|
+
"/service/{service_id}/bot",
|
|
75
|
+
),
|
|
76
|
+
# Role: DELETE /role/{id}, PUT /role with ID in body
|
|
77
|
+
"role": (
|
|
78
|
+
"/role/{id}",
|
|
79
|
+
"/role",
|
|
80
|
+
"/service/{service_id}/role",
|
|
81
|
+
),
|
|
82
|
+
# Document Requirement: Uses underscore in endpoint path
|
|
83
|
+
"documentrequirement": (
|
|
84
|
+
"/document_requirement/{id}",
|
|
85
|
+
"/document_requirement",
|
|
86
|
+
"/registration/{registration_id}/document_requirement",
|
|
87
|
+
),
|
|
88
|
+
# Cost: DELETE /cost/{id}, PUT /cost with ID in body
|
|
89
|
+
# Note: Create has fixcost/formulacost variants, handled by cost_type param
|
|
90
|
+
"cost": (
|
|
91
|
+
"/cost/{id}",
|
|
92
|
+
"/cost",
|
|
93
|
+
None, # Create endpoint depends on cost_type, handled in _get_create_endpoint
|
|
94
|
+
),
|
|
95
|
+
# Message: Global templates, DELETE /message/{id}, PUT /message, POST /message
|
|
96
|
+
"message": (
|
|
97
|
+
"/message/{id}",
|
|
98
|
+
"/message",
|
|
99
|
+
"/message",
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Alias for document_requirement (audit logs use underscore variant)
|
|
104
|
+
ROLLBACK_ENDPOINTS["document_requirement"] = ROLLBACK_ENDPOINTS["documentrequirement"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RollbackManager:
|
|
108
|
+
"""Manages rollback operations for BPA write operations.
|
|
109
|
+
|
|
110
|
+
This class handles:
|
|
111
|
+
1. Validation of rollback eligibility
|
|
112
|
+
2. Retrieval of previous state from rollback_states
|
|
113
|
+
3. Endpoint resolution based on object type
|
|
114
|
+
4. Execution of the rollback via BPA API
|
|
115
|
+
5. Marking the original operation as rolled back
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
119
|
+
"""Initialize the RollbackManager.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
db_path: Optional path to SQLite database. Uses default if not provided.
|
|
123
|
+
"""
|
|
124
|
+
self._db_path = db_path
|
|
125
|
+
|
|
126
|
+
async def _get_audit_entry(self, audit_id: str) -> dict[str, Any] | None:
|
|
127
|
+
"""Fetch an audit entry by ID.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
audit_id: The UUID of the audit entry.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict with audit entry data, or None if not found.
|
|
134
|
+
"""
|
|
135
|
+
async with get_connection(self._db_path) as conn:
|
|
136
|
+
cursor = await conn.execute(
|
|
137
|
+
"""
|
|
138
|
+
SELECT id, timestamp, user_email, operation_type, object_type,
|
|
139
|
+
object_id, params, status, result, rollback_state_id
|
|
140
|
+
FROM audit_logs
|
|
141
|
+
WHERE id = ?
|
|
142
|
+
""",
|
|
143
|
+
(audit_id,),
|
|
144
|
+
)
|
|
145
|
+
row = await cursor.fetchone()
|
|
146
|
+
if row is None:
|
|
147
|
+
return None
|
|
148
|
+
return dict(row)
|
|
149
|
+
|
|
150
|
+
async def _get_rollback_state(
|
|
151
|
+
self, rollback_state_id: str
|
|
152
|
+
) -> dict[str, Any] | None:
|
|
153
|
+
"""Fetch a rollback state by ID.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
rollback_state_id: The UUID of the rollback state.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Dict with rollback state data, or None if not found.
|
|
160
|
+
"""
|
|
161
|
+
async with get_connection(self._db_path) as conn:
|
|
162
|
+
cursor = await conn.execute(
|
|
163
|
+
"""
|
|
164
|
+
SELECT id, audit_log_id, object_type, object_id,
|
|
165
|
+
previous_state, created_at
|
|
166
|
+
FROM rollback_states
|
|
167
|
+
WHERE id = ?
|
|
168
|
+
""",
|
|
169
|
+
(rollback_state_id,),
|
|
170
|
+
)
|
|
171
|
+
row = await cursor.fetchone()
|
|
172
|
+
if row is None:
|
|
173
|
+
return None
|
|
174
|
+
return dict(row)
|
|
175
|
+
|
|
176
|
+
def _check_already_rolled_back(self, result: dict[str, Any] | None) -> bool:
|
|
177
|
+
"""Check if an operation was already rolled back.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
result: The result field from audit_logs (parsed JSON).
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if already rolled back, False otherwise.
|
|
184
|
+
"""
|
|
185
|
+
if result is None:
|
|
186
|
+
return False
|
|
187
|
+
return "rolled_back_at" in result
|
|
188
|
+
|
|
189
|
+
async def validate_rollback(self, audit_id: str) -> dict[str, Any]:
|
|
190
|
+
"""Validate that a rollback can be performed for the given audit entry.
|
|
191
|
+
|
|
192
|
+
This performs all pre-flight checks:
|
|
193
|
+
1. Audit entry exists
|
|
194
|
+
2. Operation status is 'success' (not failed or pending)
|
|
195
|
+
3. Operation has not already been rolled back
|
|
196
|
+
4. Rollback state exists
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
audit_id: The UUID of the audit entry to validate.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The audit entry dict if validation passes.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
RollbackNotPossibleError: If rollback cannot be performed.
|
|
206
|
+
"""
|
|
207
|
+
# Check audit entry exists
|
|
208
|
+
entry = await self._get_audit_entry(audit_id)
|
|
209
|
+
if entry is None:
|
|
210
|
+
raise RollbackNotPossibleError(
|
|
211
|
+
f"Audit entry '{audit_id}' not found. "
|
|
212
|
+
"Use 'audit_list' to see available entries."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Check status
|
|
216
|
+
status = entry["status"]
|
|
217
|
+
if status == "failed":
|
|
218
|
+
raise RollbackNotPossibleError(
|
|
219
|
+
f"Operation '{audit_id}' failed and made no changes. "
|
|
220
|
+
"Nothing to rollback."
|
|
221
|
+
)
|
|
222
|
+
if status == "pending":
|
|
223
|
+
raise RollbackNotPossibleError(
|
|
224
|
+
f"Operation '{audit_id}' is still pending. "
|
|
225
|
+
"Wait for it to complete before attempting rollback."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Check if already rolled back
|
|
229
|
+
result = json.loads(entry["result"]) if entry["result"] else None
|
|
230
|
+
if self._check_already_rolled_back(result):
|
|
231
|
+
# result is guaranteed to be a dict if check returns True
|
|
232
|
+
assert result is not None
|
|
233
|
+
rolled_back_at = result.get("rolled_back_at", "unknown time")
|
|
234
|
+
raise RollbackNotPossibleError(
|
|
235
|
+
f"Operation '{audit_id}' was already rolled back at {rolled_back_at}. "
|
|
236
|
+
"Use 'audit_get' to see current state."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Check rollback state exists
|
|
240
|
+
if entry["rollback_state_id"] is None:
|
|
241
|
+
raise RollbackNotPossibleError(
|
|
242
|
+
f"Operation '{audit_id}' has no saved state for rollback. "
|
|
243
|
+
"This may be an older operation before rollback was enabled."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return entry
|
|
247
|
+
|
|
248
|
+
def _get_delete_endpoint(
|
|
249
|
+
self, object_type: str, object_id: str, params: dict[str, Any]
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Resolve the DELETE endpoint for an object type.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
object_type: The type of object (service, registration, etc.)
|
|
255
|
+
object_id: The ID of the object to delete.
|
|
256
|
+
params: Additional parameters for endpoint resolution (service_id, etc.)
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The resolved DELETE endpoint path.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
RollbackError: If object type not supported or endpoint not available.
|
|
263
|
+
"""
|
|
264
|
+
if object_type not in ROLLBACK_ENDPOINTS:
|
|
265
|
+
raise RollbackError(
|
|
266
|
+
f"Rollback not supported for object type '{object_type}'."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
delete_endpoint, _, _ = ROLLBACK_ENDPOINTS[object_type]
|
|
270
|
+
if delete_endpoint is None:
|
|
271
|
+
raise RollbackError(
|
|
272
|
+
f"DELETE operation not available for object type '{object_type}'."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Substitute placeholders
|
|
276
|
+
endpoint = delete_endpoint.replace("{id}", str(object_id))
|
|
277
|
+
if "{service_id}" in endpoint:
|
|
278
|
+
service_id = params.get("service_id")
|
|
279
|
+
if not service_id:
|
|
280
|
+
raise RollbackError(
|
|
281
|
+
f"Cannot resolve DELETE endpoint for '{object_type}': "
|
|
282
|
+
"service_id not found in params."
|
|
283
|
+
)
|
|
284
|
+
endpoint = endpoint.replace("{service_id}", str(service_id))
|
|
285
|
+
|
|
286
|
+
return endpoint
|
|
287
|
+
|
|
288
|
+
def _get_update_endpoint(
|
|
289
|
+
self, object_type: str, object_id: str, params: dict[str, Any]
|
|
290
|
+
) -> str:
|
|
291
|
+
"""Resolve the PUT endpoint for an object type.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
object_type: The type of object (service, registration, etc.)
|
|
295
|
+
object_id: The ID of the object to update.
|
|
296
|
+
params: Additional parameters for endpoint resolution.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The resolved PUT endpoint path.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
RollbackError: If object type not supported or endpoint not available.
|
|
303
|
+
"""
|
|
304
|
+
if object_type not in ROLLBACK_ENDPOINTS:
|
|
305
|
+
raise RollbackError(
|
|
306
|
+
f"Rollback not supported for object type '{object_type}'."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
_, update_endpoint, _ = ROLLBACK_ENDPOINTS[object_type]
|
|
310
|
+
if update_endpoint is None:
|
|
311
|
+
raise RollbackError(
|
|
312
|
+
f"UPDATE operation not available for object type '{object_type}'."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Substitute placeholders
|
|
316
|
+
endpoint = update_endpoint.replace("{id}", str(object_id))
|
|
317
|
+
if "{service_id}" in endpoint:
|
|
318
|
+
service_id = params.get("service_id")
|
|
319
|
+
if not service_id:
|
|
320
|
+
raise RollbackError(
|
|
321
|
+
f"Cannot resolve UPDATE endpoint for '{object_type}': "
|
|
322
|
+
"service_id not found in params."
|
|
323
|
+
)
|
|
324
|
+
endpoint = endpoint.replace("{service_id}", str(service_id))
|
|
325
|
+
|
|
326
|
+
return endpoint
|
|
327
|
+
|
|
328
|
+
def _get_create_endpoint(self, object_type: str, params: dict[str, Any]) -> str:
|
|
329
|
+
"""Resolve the POST endpoint for an object type.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
object_type: The type of object (service, registration, etc.)
|
|
333
|
+
params: Additional parameters for endpoint resolution.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The resolved POST endpoint path.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
RollbackError: If object type not supported or endpoint not available.
|
|
340
|
+
"""
|
|
341
|
+
if object_type not in ROLLBACK_ENDPOINTS:
|
|
342
|
+
raise RollbackError(
|
|
343
|
+
f"Rollback not supported for object type '{object_type}'."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Special case: cost has two create endpoints depending on cost_type
|
|
347
|
+
if object_type == "cost":
|
|
348
|
+
cost_type = params.get("cost_type", "fixed")
|
|
349
|
+
registration_id = params.get("registration_id")
|
|
350
|
+
if not registration_id:
|
|
351
|
+
raise RollbackError(
|
|
352
|
+
"Cannot resolve CREATE endpoint for 'cost': "
|
|
353
|
+
"registration_id not found in params."
|
|
354
|
+
)
|
|
355
|
+
if cost_type == "formula":
|
|
356
|
+
return f"/registration/{registration_id}/formulacost"
|
|
357
|
+
return f"/registration/{registration_id}/fixcost"
|
|
358
|
+
|
|
359
|
+
_, _, create_endpoint = ROLLBACK_ENDPOINTS[object_type]
|
|
360
|
+
if create_endpoint is None:
|
|
361
|
+
raise RollbackError(
|
|
362
|
+
f"CREATE operation not available for object type '{object_type}'."
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Substitute placeholders
|
|
366
|
+
endpoint = create_endpoint
|
|
367
|
+
if "{service_id}" in endpoint:
|
|
368
|
+
service_id = params.get("service_id")
|
|
369
|
+
if not service_id:
|
|
370
|
+
raise RollbackError(
|
|
371
|
+
f"Cannot resolve CREATE endpoint for '{object_type}': "
|
|
372
|
+
"service_id not found in params."
|
|
373
|
+
)
|
|
374
|
+
endpoint = endpoint.replace("{service_id}", str(service_id))
|
|
375
|
+
if "{registration_id}" in endpoint:
|
|
376
|
+
registration_id = params.get("registration_id")
|
|
377
|
+
if not registration_id:
|
|
378
|
+
raise RollbackError(
|
|
379
|
+
f"Cannot resolve CREATE endpoint for '{object_type}': "
|
|
380
|
+
"registration_id not found in params."
|
|
381
|
+
)
|
|
382
|
+
endpoint = endpoint.replace("{registration_id}", str(registration_id))
|
|
383
|
+
|
|
384
|
+
return endpoint
|
|
385
|
+
|
|
386
|
+
async def execute_rollback(
|
|
387
|
+
self,
|
|
388
|
+
operation_type: str,
|
|
389
|
+
object_type: str,
|
|
390
|
+
object_id: str,
|
|
391
|
+
previous_state: dict[str, Any] | None,
|
|
392
|
+
params: dict[str, Any],
|
|
393
|
+
) -> dict[str, Any]:
|
|
394
|
+
"""Execute the rollback operation via BPA API.
|
|
395
|
+
|
|
396
|
+
Strategy by operation type:
|
|
397
|
+
- create: DELETE the object (no previous state needed)
|
|
398
|
+
- update: PUT with previous_state values
|
|
399
|
+
- delete: POST to recreate with previous_state
|
|
400
|
+
- link: Call unlink operation
|
|
401
|
+
- unlink: Call link operation
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
operation_type: The original operation type (create, update, delete, etc.)
|
|
405
|
+
object_type: The type of object.
|
|
406
|
+
object_id: The ID of the object.
|
|
407
|
+
previous_state: The state to restore (None for create operations).
|
|
408
|
+
params: Additional context parameters from the original operation.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Dict with rollback execution result.
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
RollbackError: If the BPA API call fails.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
async with BPAClient() as client:
|
|
418
|
+
if operation_type == "create":
|
|
419
|
+
# Delete the created object
|
|
420
|
+
endpoint = self._get_delete_endpoint(object_type, object_id, params)
|
|
421
|
+
await client.delete(endpoint)
|
|
422
|
+
return {"action": "deleted", "object_id": object_id}
|
|
423
|
+
|
|
424
|
+
elif operation_type == "update":
|
|
425
|
+
# Restore previous values
|
|
426
|
+
endpoint = self._get_update_endpoint(object_type, object_id, params)
|
|
427
|
+
result = await client.put(endpoint, json=previous_state)
|
|
428
|
+
return {"action": "restored", "state": result}
|
|
429
|
+
|
|
430
|
+
elif operation_type == "delete":
|
|
431
|
+
# Recreate the object
|
|
432
|
+
endpoint = self._get_create_endpoint(object_type, params)
|
|
433
|
+
|
|
434
|
+
# Messages: strip ID fields (BPA rejects recreating with same ID)
|
|
435
|
+
# Other types: preserve original payload
|
|
436
|
+
if object_type == "message":
|
|
437
|
+
create_payload = {
|
|
438
|
+
k: v
|
|
439
|
+
for k, v in (previous_state or {}).items()
|
|
440
|
+
if k not in ("id", "businessKey", "business_key")
|
|
441
|
+
}
|
|
442
|
+
result = await client.post(endpoint, json=create_payload)
|
|
443
|
+
return {
|
|
444
|
+
"action": "recreated",
|
|
445
|
+
"new_id": result.get("id") if result else None,
|
|
446
|
+
"original_id": (
|
|
447
|
+
previous_state.get("id") if previous_state else None
|
|
448
|
+
),
|
|
449
|
+
}
|
|
450
|
+
else:
|
|
451
|
+
result = await client.post(endpoint, json=previous_state)
|
|
452
|
+
return {
|
|
453
|
+
"action": "recreated",
|
|
454
|
+
"new_id": result.get("id") if result else None,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
elif operation_type == "link":
|
|
458
|
+
# TODO: Implement link rollback (unlink)
|
|
459
|
+
raise RollbackError(
|
|
460
|
+
f"Rollback for '{operation_type}' operations "
|
|
461
|
+
"is not yet implemented."
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
elif operation_type == "unlink":
|
|
465
|
+
# TODO: Implement unlink rollback (link)
|
|
466
|
+
raise RollbackError(
|
|
467
|
+
f"Rollback for '{operation_type}' operations "
|
|
468
|
+
"is not yet implemented."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
else:
|
|
472
|
+
raise RollbackError(
|
|
473
|
+
f"Unknown operation type '{operation_type}'. "
|
|
474
|
+
"Cannot determine rollback strategy."
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
except BPAClientError as e:
|
|
478
|
+
raise RollbackError(f"BPA API error during rollback: {e}")
|
|
479
|
+
|
|
480
|
+
async def _mark_rolled_back(
|
|
481
|
+
self,
|
|
482
|
+
audit_id: str,
|
|
483
|
+
rollback_audit_id: str,
|
|
484
|
+
rolled_back_at: str,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Mark the original audit entry as rolled back.
|
|
487
|
+
|
|
488
|
+
Updates the result field of the original audit entry to include
|
|
489
|
+
rollback metadata, preventing double-rollback.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
audit_id: The original audit entry ID.
|
|
493
|
+
rollback_audit_id: The ID of the rollback audit entry.
|
|
494
|
+
rolled_back_at: ISO 8601 timestamp of when rollback occurred.
|
|
495
|
+
"""
|
|
496
|
+
async with get_connection(self._db_path) as conn:
|
|
497
|
+
# Get current result
|
|
498
|
+
cursor = await conn.execute(
|
|
499
|
+
"SELECT result FROM audit_logs WHERE id = ?",
|
|
500
|
+
(audit_id,),
|
|
501
|
+
)
|
|
502
|
+
row = await cursor.fetchone()
|
|
503
|
+
if row is None:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# Update result with rollback info
|
|
507
|
+
current_result = json.loads(row["result"]) if row["result"] else {}
|
|
508
|
+
current_result["rolled_back_at"] = rolled_back_at
|
|
509
|
+
current_result["rollback_audit_id"] = rollback_audit_id
|
|
510
|
+
|
|
511
|
+
await conn.execute(
|
|
512
|
+
"UPDATE audit_logs SET result = ? WHERE id = ?",
|
|
513
|
+
(json.dumps(current_result), audit_id),
|
|
514
|
+
)
|
|
515
|
+
await conn.commit()
|
|
516
|
+
|
|
517
|
+
async def perform_rollback(self, audit_id: str) -> dict[str, Any]:
|
|
518
|
+
"""Perform a complete rollback operation.
|
|
519
|
+
|
|
520
|
+
This is the main entry point for rollback operations. It:
|
|
521
|
+
1. Validates the rollback can be performed
|
|
522
|
+
2. Retrieves the previous state
|
|
523
|
+
3. Executes the rollback via BPA API
|
|
524
|
+
4. Marks the original operation as rolled back
|
|
525
|
+
|
|
526
|
+
Note: This method does NOT create an audit record for the rollback
|
|
527
|
+
operation itself - that should be done by the calling tool.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
audit_id: The UUID of the audit entry to rollback.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Dict with rollback result including:
|
|
534
|
+
- status: "success"
|
|
535
|
+
- message: Human-readable description
|
|
536
|
+
- original_operation: Details of what was rolled back
|
|
537
|
+
- restored_state: The restored object state (if applicable)
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
RollbackNotPossibleError: If validation fails.
|
|
541
|
+
RollbackError: If execution fails.
|
|
542
|
+
"""
|
|
543
|
+
# Validate rollback
|
|
544
|
+
entry = await self.validate_rollback(audit_id)
|
|
545
|
+
|
|
546
|
+
# Get rollback state
|
|
547
|
+
rollback_state = await self._get_rollback_state(entry["rollback_state_id"])
|
|
548
|
+
if rollback_state is None:
|
|
549
|
+
raise RollbackError(
|
|
550
|
+
f"Rollback state '{entry['rollback_state_id']}' not found. "
|
|
551
|
+
"Database may be corrupted."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Parse previous state
|
|
555
|
+
previous_state = (
|
|
556
|
+
json.loads(rollback_state["previous_state"])
|
|
557
|
+
if rollback_state["previous_state"]
|
|
558
|
+
else None
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Parse params for context (service_id, registration_id, etc.)
|
|
562
|
+
params = json.loads(entry["params"]) if entry["params"] else {}
|
|
563
|
+
|
|
564
|
+
# Execute rollback
|
|
565
|
+
operation_type = entry["operation_type"]
|
|
566
|
+
object_type = entry["object_type"]
|
|
567
|
+
# For create operations, object_id in audit_logs is None (we don't know ID
|
|
568
|
+
# until after creation). Use object_id from rollback_state as fallback.
|
|
569
|
+
object_id = entry["object_id"] or rollback_state["object_id"]
|
|
570
|
+
|
|
571
|
+
exec_result = await self.execute_rollback(
|
|
572
|
+
operation_type=operation_type,
|
|
573
|
+
object_type=object_type,
|
|
574
|
+
object_id=object_id,
|
|
575
|
+
previous_state=previous_state,
|
|
576
|
+
params=params,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Build message
|
|
580
|
+
action = exec_result.get("action", "unknown")
|
|
581
|
+
if action == "deleted":
|
|
582
|
+
message = (
|
|
583
|
+
f"Rolled back 'create' on {object_type} '{object_id}' - object deleted"
|
|
584
|
+
)
|
|
585
|
+
elif action == "restored":
|
|
586
|
+
message = f"Rolled back 'update' on {object_type} '{object_id}'"
|
|
587
|
+
elif action == "recreated":
|
|
588
|
+
new_id = exec_result.get("new_id", "unknown")
|
|
589
|
+
message = (
|
|
590
|
+
f"Rolled back 'delete' on {object_type} '{object_id}' - "
|
|
591
|
+
f"object recreated as '{new_id}'"
|
|
592
|
+
)
|
|
593
|
+
else:
|
|
594
|
+
message = f"Rolled back '{operation_type}' on {object_type} '{object_id}'"
|
|
595
|
+
|
|
596
|
+
# Build response
|
|
597
|
+
rolled_back_at = datetime.now(UTC).isoformat()
|
|
598
|
+
result: dict[str, Any] = {
|
|
599
|
+
"status": "success",
|
|
600
|
+
"message": message,
|
|
601
|
+
"original_operation": {
|
|
602
|
+
"audit_id": entry["id"],
|
|
603
|
+
"operation_type": operation_type,
|
|
604
|
+
"object_type": object_type,
|
|
605
|
+
"object_id": object_id,
|
|
606
|
+
"timestamp": entry["timestamp"],
|
|
607
|
+
},
|
|
608
|
+
"restored_state": previous_state,
|
|
609
|
+
"rolled_back_at": rolled_back_at,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
# Add new_id for recreated objects
|
|
613
|
+
if action == "recreated" and exec_result.get("new_id"):
|
|
614
|
+
result["new_object_id"] = exec_result["new_id"]
|
|
615
|
+
|
|
616
|
+
return result
|