-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathdeployment_protection_rule.py
More file actions
459 lines (406 loc) · 19.9 KB
/
Copy pathdeployment_protection_rule.py
File metadata and controls
459 lines (406 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
import logging
import time
from typing import Any
from src.agents import get_agent
from src.core.utils.retry import retry_async
from src.core.utils.timeout import execute_with_timeout
from src.event_processors.base import BaseEventProcessor, ProcessingResult
from src.tasks.scheduler.deployment_scheduler import get_deployment_scheduler
from src.tasks.task_queue import Task
logger = logging.getLogger(__name__)
AGENT_TIMEOUT_SECONDS = 30.0
class DeploymentProtectionRuleProcessor(BaseEventProcessor):
"""Processor for deployment protection rule events using hybrid agentic rule evaluation."""
def __init__(self):
"""Initialize deployment protection rule processor with hybrid rule engine agent."""
# Call super class __init__ first
super().__init__()
# Create instance of hybrid RuleEngineAgent
self.engine_agent = get_agent("engine")
def get_event_type(self) -> str:
"""Return the event type this processor handles."""
return "deployment_protection_rule"
@staticmethod
def _is_valid_callback_url(url: str | None) -> bool:
return bool(url and isinstance(url, str) and url.strip().startswith("http"))
@staticmethod
def _is_valid_environment(env: str | None) -> bool:
return bool(env and isinstance(env, str) and env.strip())
async def process(self, task: Task) -> ProcessingResult:
"""Process deployment protection rule event with hybrid rule evaluation.
This method orchestrates the deployment approval/rejection workflow:
1. Validates callback URL and environment from webhook payload
2. Loads deployment rules from repository configuration
3. Enriches event data with commit/deployment metadata
4. Evaluates rules using hybrid agent (deterministic + LLM fallback)
5. Handles time-based scheduling for delayed deployment windows
6. Approves/rejects deployment via GitHub API callback
7. Posts check run with evaluation results
Args:
task: Task containing deployment_protection_rule event payload with:
- deployment: Deployment metadata (id, sha, ref, environment)
- deployment_callback_url: GitHub API endpoint for approval/rejection
- environment: Target deployment environment name
- installation_id: GitHub App installation identifier
- repo_full_name: Repository in owner/name format
Returns:
ProcessingResult with:
- success: True if deployment was approved/rejected successfully
- violations: List of rule violations that blocked deployment
- api_calls_made: Count of GitHub API calls (approx)
- processing_time_ms: Total processing time in milliseconds
- error: Error message if processing failed
Side Effects:
- Calls GitHub deployment approval/rejection API
- Creates check run with evaluation details
- Schedules delayed deployment approval via deployment scheduler
- Logs structured events at decision boundaries
Error Handling:
- Retries approval API calls with exponential backoff (3 attempts)
- Falls back to LLM if deterministic evaluation fails
- Returns success=False with error message on unrecoverable failures
- Gracefully degrades if rules file is missing or malformed
"""
start_time = time.time()
try:
payload = task.payload
environment = payload.get("environment")
deployment = payload.get("deployment", {})
deployment_id = deployment.get("id")
deployment_callback_url = payload.get("deployment_callback_url")
installation_id = task.installation_id
repo_full_name = task.repo_full_name
can_call_callback = self._is_valid_callback_url(deployment_callback_url) and self._is_valid_environment(
environment
)
if not can_call_callback:
logger.warning(
"deployment_status_skipped",
extra={
"operation": "deployment_protection_rule",
"deployment_id": deployment_id,
"environment": environment,
"reason": "invalid or missing callback_url or environment",
},
)
logger.info(
"deployment_processing_start",
extra={
"operation": "deployment_protection_rule",
"deployment_id": deployment_id,
"environment": environment,
"repo": repo_full_name,
},
)
rules = await self.rule_provider.get_rules(repo_full_name, installation_id)
if not rules:
logger.info("No rules found for repository")
if can_call_callback:
approved = await self._approve_deployment(
deployment_callback_url, environment, "No rules configured", installation_id
)
if not approved:
return ProcessingResult(
success=False,
violations=[],
api_calls_made=1,
processing_time_ms=int((time.time() - start_time) * 1000),
error="Approval API failed after retries",
)
return ProcessingResult(
success=True,
violations=[],
api_calls_made=1,
processing_time_ms=int((time.time() - start_time) * 1000),
)
deployment_rules = []
for r in rules:
if hasattr(r, "event_types"):
event_types = [et.value if hasattr(et, "value") else et for et in r.event_types]
elif isinstance(r, dict):
event_types = r.get("event_types", [])
else:
logger.error("rule_invalid", extra={"rule": str(r), "rule_type": type(r).__name__})
continue
if "deployment" in event_types:
deployment_rules.append(r)
if not deployment_rules:
logger.info("No deployment rules found")
if can_call_callback:
approved = await self._approve_deployment(
deployment_callback_url,
environment,
"No deployment rules configured",
installation_id,
)
if not approved:
return ProcessingResult(
success=False,
violations=[],
api_calls_made=1,
processing_time_ms=int((time.time() - start_time) * 1000),
error="Approval API failed after retries",
)
return ProcessingResult(
success=True,
violations=[],
api_calls_made=1,
processing_time_ms=int((time.time() - start_time) * 1000),
)
logger.info("Found %d applicable rules for deployment", len(deployment_rules))
formatted_rules = self._convert_rules_to_new_format(deployment_rules)
event_data = {
"deployment": deployment,
"triggering_user": deployment.get("creator", {}),
"repository": payload.get("repository", {}),
"organization": payload.get("organization", {}),
"event_id": payload.get("event_id"),
"timestamp": payload.get("timestamp"),
"installation": {"id": task.installation_id},
"github_client": self.github_client, # Pass GitHub client for validators
}
analysis_result = await execute_with_timeout(
self.engine_agent.execute(
event_type="deployment",
event_data=event_data,
rules=formatted_rules,
),
timeout=AGENT_TIMEOUT_SECONDS,
timeout_message=f"Agent execution timed out after {AGENT_TIMEOUT_SECONDS}s",
)
violations = []
if analysis_result.data and "evaluation_result" in analysis_result.data:
eval_result = analysis_result.data["evaluation_result"]
if hasattr(eval_result, "violations"):
violations = [
v.model_dump(mode="json") if hasattr(v, "model_dump") else v for v in eval_result.violations
]
logger.info("Analysis completed: %d violations", len(violations))
for violation in violations:
logger.info("Violation: %s", violation.get("message", "Unknown violation"))
if not violations:
if can_call_callback:
approved = await self._approve_deployment(
deployment_callback_url, environment, "All deployment rules passed", installation_id
)
if not approved:
return ProcessingResult(
success=False,
violations=[],
api_calls_made=1,
processing_time_ms=int((time.time() - start_time) * 1000),
error="Approval API failed after retries",
)
logger.info("All rules passed, deployment approved")
else:
time_based_violations = self._check_time_based_violations(violations)
if time_based_violations and can_call_callback:
await get_deployment_scheduler().add_pending_deployment(
{
"deployment_id": deployment_id,
"repo": task.repo_full_name,
"installation_id": task.installation_id,
"environment": environment or deployment.get("environment"),
"event_data": payload,
"rules": deployment_rules,
"violations": violations,
"time_based_violations": time_based_violations,
"created_at": time.time(),
"callback_url": deployment_callback_url,
}
)
logger.info("Time-based violations detected, added to scheduler for re-evaluation")
if can_call_callback:
rejected = await self._reject_deployment(
deployment_callback_url, environment, violations, installation_id
)
if not rejected:
return ProcessingResult(
success=False,
violations=violations,
api_calls_made=1,
processing_time_ms=int((time.time() - start_time) * 1000),
error="Rejection API failed after retries",
)
logger.info("Deployment rejected due to %d violations", len(violations))
processing_time = int((time.time() - start_time) * 1000)
logger.info(
"deployment_processing_complete",
extra={
"operation": "deployment_protection_rule",
"deployment_id": deployment_id,
"environment": environment,
"processing_time_ms": processing_time,
"state": "approved" if not violations else "rejected",
"violations_count": len(violations),
},
)
return ProcessingResult(
success=(not violations), violations=violations, api_calls_made=1, processing_time_ms=processing_time
)
except Exception as e:
processing_time = int((time.time() - start_time) * 1000)
exc_payload = task.payload
exc_deployment = exc_payload.get("deployment", {})
exc_deployment_id = exc_deployment.get("id")
exc_callback_url = exc_payload.get("deployment_callback_url")
exc_environment = exc_payload.get("environment")
logger.error(
"deployment_processing_error",
extra={
"operation": "deployment_protection_rule",
"deployment_id": exc_deployment_id,
"error": str(e),
"processing_time_ms": processing_time,
},
)
if self._is_valid_callback_url(exc_callback_url) and self._is_valid_environment(exc_environment):
fallback_comment = "Processing failed. Approved as fallback to avoid indefinite blocking."
approved = await self._approve_deployment(
exc_callback_url, exc_environment, fallback_comment, task.installation_id
)
if approved:
logger.info(
"deployment_fallback_approval",
extra={
"operation": "deployment_protection_rule",
"deployment_id": exc_deployment_id,
"reason": "exception during processing",
},
)
else:
logger.warning(
"deployment_fallback_approval_failed",
extra={
"operation": "deployment_protection_rule",
"deployment_id": exc_deployment_id,
"reason": "fallback approval API failed after retries",
},
)
return ProcessingResult(
success=False,
violations=[],
api_calls_made=0,
processing_time_ms=processing_time,
error=f"{e!s}. Fallback approval also failed.",
)
return ProcessingResult(
success=False,
violations=[],
api_calls_made=0,
processing_time_ms=processing_time,
error=str(e),
)
@staticmethod
def _check_time_based_violations(violations: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
v
for v in violations
if any(k in v.get("rule_description", "").lower() for k in ["hours", "weekend", "time", "day"])
]
async def _send_deployment_review(
self,
callback_url: str,
environment: str,
state: str,
comment: str,
installation_id: int,
) -> bool:
async def _do_send() -> dict[str, Any]:
result = await self.github_client.review_deployment_protection_rule(
callback_url=callback_url,
environment=environment,
state=state,
comment=comment,
installation_id=installation_id,
)
if result is None:
raise RuntimeError("review_deployment_protection_rule returned None")
return result
try:
await retry_async(
_do_send,
max_retries=3,
initial_delay=1.0,
max_delay=30.0,
exceptions=(Exception,),
)
logger.info("deployment_review_sent", extra={"operation": state, "environment": environment})
return True
except Exception as e:
logger.error(
"deployment_review_error",
extra={"operation": state, "environment": environment, "error": str(e)},
)
return False
async def _approve_deployment(
self, callback_url: str, environment: str, comment: str, installation_id: int
) -> bool:
return await self._send_deployment_review(callback_url, environment, "approved", comment, installation_id)
async def _reject_deployment(
self, callback_url: str, environment: str, violations: list[dict[str, Any]], installation_id: int
) -> bool:
comment_text = self._format_violations_comment(violations)
return await self._send_deployment_review(callback_url, environment, "rejected", comment_text, installation_id)
def _convert_rules_to_new_format(self, rules: list[Any]) -> list[dict[str, Any]]:
formatted_rules = []
for rule in rules:
if isinstance(rule, dict):
rule_dict = {
"description": rule.get("description", rule.get("rule_description", "")),
"enabled": rule.get("enabled", True),
"severity": rule.get("severity", "medium"),
"event_types": rule.get("event_types", []),
"parameters": rule.get("parameters", {}),
}
else:
rule_dict = {
"description": getattr(rule, "description", ""),
"enabled": getattr(rule, "enabled", True),
"severity": (
rule.severity.value if hasattr(rule.severity, "value") else getattr(rule, "severity", "medium")
),
"event_types": [
et.value if hasattr(et, "value") else et for et in getattr(rule, "event_types", [])
],
"parameters": getattr(rule, "parameters", {}) or {},
}
if not rule_dict["parameters"] and hasattr(rule, "conditions"):
for condition in rule.conditions:
rule_dict["parameters"].update(getattr(condition, "parameters", {}))
formatted_rules.append(rule_dict)
return formatted_rules
@staticmethod
def _format_violations_comment(violations):
text = "**Deployment Blocked - Rule Violations Detected**\n"
for v in violations:
rule_description = v.get("rule_description", v.get("rule", v.get("description", "Unknown")))
text += f"**{rule_description}**\n"
text += f"**Severity:** {v.get('severity', 'high').capitalize()}\n"
text += f"**Issue:** {v.get('message', '')}\n"
text += f"**Solution:** {v.get('how_to_fix', 'See documentation.')}\n"
text += "\n---\n*This review was performed automatically by Watchflow.*"
return text
async def prepare_webhook_data(self, task: Task) -> dict[str, Any]:
"""Extract data from webhook payload for rule evaluation.
Returns the raw payload as-is since deployment_protection_rule events
contain all necessary data (deployment, environment, callback URL).
Args:
task: Task with deployment_protection_rule payload
Returns:
Dictionary with deployment event data from webhook
"""
return task.payload
async def prepare_api_data(self, task: Task) -> dict[str, Any]:
"""Fetch additional data via GitHub API for rule evaluation.
For deployment_protection_rule events, all necessary data is already
in the webhook payload, so no additional API calls are needed.
Args:
task: Task with deployment_protection_rule payload
Returns:
Empty dictionary (no additional API data required)
"""
return {}
def _get_rule_provider(self):
from src.rules.loaders.github_loader import github_rule_loader
return github_rule_loader