Skip to content

Commit 4222c29

Browse files
authored
Refactor/api webhooks handler (#949)
* fix: ensure proper handling of alert/info hook type * feat: isolate log_ops_message feature and handle errors * feat: add configuration settings for SRE Ops * refactor: fix and reorder import statements * fix: removed former log_ops_message function * refactor: migrate webhook payload handling to dedicated module * refactor: migrate AWS SNS handling to dedicated module * chore: remove migrated functions modules * refactor: isolate AWS processor to dedicated module * refactor: isolate AWS processor to dedicated module * feat: add SlackClientManager * refactor: simplify log_ops_message by using SlackClientManager * refactor: remove client param from log_ops_message calls * chore: fmt
1 parent c4fa9f4 commit 4222c29

File tree

18 files changed

+681
-444
lines changed

18 files changed

+681
-444
lines changed

app/api/v1/routes/webhooks.py

Lines changed: 2 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
import json
2-
from typing import Union, Dict, Any, cast
2+
from typing import Union, Dict, Any
33

4-
import requests # type: ignore
54
from api.dependencies.rate_limits import get_limiter
65
from core.logging import get_module_logger
76
from fastapi import APIRouter, HTTPException, Request, Body
87
from integrations.sentinel import log_to_sentinel
98
from models.webhooks import (
10-
AwsSnsPayload,
119
WebhookPayload,
12-
AccessRequest,
13-
UpptimePayload,
14-
WebhookResult,
1510
)
1611
from modules.slack import webhooks
17-
from modules.webhooks.base import validate_payload
18-
from server.event_handlers import aws
19-
from server.utils import log_ops_message
12+
from modules.webhooks.base import handle_webhook_payload
2013

2114

2215
logger = get_module_logger()
@@ -107,125 +100,6 @@ def handle_webhook(
107100
return {"ok": True}
108101

109102

110-
def handle_webhook_payload(
111-
payload_dict: dict,
112-
request: Request,
113-
) -> WebhookResult:
114-
"""Process and validate the webhook payload.
115-
116-
Returns:
117-
dict: A dictionary containing:
118-
- status (str): The status of the operation (e.g., "success", "error").
119-
- action (Literal["post", "log", "none"]): The action to take.
120-
- payload (Optional[WebhookPayload]): The payload to post, if applicable.
121-
"""
122-
logger.info("processing_webhook_payload", payload=payload_dict)
123-
payload_validation_result = validate_payload(payload_dict)
124-
125-
webhook_result = WebhookResult(
126-
status="error", message="Failed to process payload for unknown reasons"
127-
)
128-
if payload_validation_result is not None:
129-
payload_type, validated_payload = payload_validation_result
130-
else:
131-
error_message = "No matching model found for payload"
132-
return WebhookResult(status="error", message=error_message)
133-
134-
match payload_type.__name__:
135-
case "WebhookPayload":
136-
webhook_result = WebhookResult(
137-
status="success", action="post", payload=validated_payload
138-
)
139-
case "AwsSnsPayload":
140-
aws_sns_payload_instance = cast(AwsSnsPayload, validated_payload)
141-
aws_sns_payload = aws.validate_sns_payload(
142-
aws_sns_payload_instance,
143-
request.state.bot.client,
144-
)
145-
146-
if aws_sns_payload.Type == "SubscriptionConfirmation":
147-
requests.get(aws_sns_payload.SubscribeURL, timeout=60)
148-
logger.info(
149-
"subscribed_webhook_to_topic",
150-
webhook_id=aws_sns_payload.TopicArn,
151-
subscribed_topic=aws_sns_payload.TopicArn,
152-
)
153-
log_ops_message(
154-
request.state.bot.client,
155-
f"Subscribed webhook {id} to topic {aws_sns_payload.TopicArn}",
156-
)
157-
webhook_result = WebhookResult(
158-
status="success", action="log", payload=None
159-
)
160-
161-
if aws_sns_payload.Type == "UnsubscribeConfirmation":
162-
log_ops_message(
163-
request.state.bot.client,
164-
f"{aws_sns_payload.TopicArn} unsubscribed from webhook {id}",
165-
)
166-
webhook_result = WebhookResult(
167-
status="success", action="log", payload=None
168-
)
169-
170-
if aws_sns_payload.Type == "Notification":
171-
blocks = aws.parse(aws_sns_payload, request.state.bot.client)
172-
if not blocks:
173-
logger.info(
174-
"payload_empty_message",
175-
payload_type="AwsSnsPayload",
176-
sns_type=aws_sns_payload.Type,
177-
)
178-
return WebhookResult(
179-
status="error",
180-
action="none",
181-
message="Empty AWS SNS Notification message",
182-
)
183-
webhook_result = WebhookResult(
184-
status="success",
185-
action="post",
186-
payload=WebhookPayload(blocks=blocks),
187-
)
188-
189-
case "AccessRequest":
190-
message = str(cast(AccessRequest, validated_payload).model_dump())
191-
webhook_result = WebhookResult(
192-
status="success",
193-
action="post",
194-
payload=WebhookPayload(text=message),
195-
)
196-
197-
case "UpptimePayload":
198-
text = cast(UpptimePayload, validated_payload).text
199-
header_text = "📈 Web Application Status Changed!"
200-
blocks = [
201-
{"type": "section", "text": {"type": "mrkdwn", "text": " "}},
202-
{
203-
"type": "header",
204-
"text": {"type": "plain_text", "text": f"{header_text}"},
205-
},
206-
{
207-
"type": "section",
208-
"text": {
209-
"type": "mrkdwn",
210-
"text": f"{text}",
211-
},
212-
},
213-
]
214-
webhook_result = WebhookResult(
215-
status="success",
216-
action="post",
217-
payload=WebhookPayload(blocks=blocks),
218-
)
219-
220-
case _:
221-
webhook_result = WebhookResult(
222-
status="error",
223-
message="No matching model found for payload",
224-
)
225-
226-
return webhook_result
227-
228-
229103
def append_incident_buttons(payload: WebhookPayload, webhook_id) -> WebhookPayload:
230104
if payload.attachments is None:
231105
payload.attachments = []

app/core/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ class TalentRoleSettings(BaseSettings):
190190

191191

192192
class ReportsSettings(BaseSettings):
193+
"""Reports configuration settings."""
194+
193195
FOLDER_REPORTS_GOOGLE_GROUPS: str = Field(
194196
default="", alias="FOLDER_REPORTS_GOOGLE_GROUPS"
195197
)
@@ -202,6 +204,8 @@ class ReportsSettings(BaseSettings):
202204

203205

204206
class AWSFeatureSettings(BaseSettings):
207+
"""AWS Feature configuration settings."""
208+
205209
AWS_ADMIN_GROUPS: list[str] = Field(
206210
default=["[email protected]"], alias="AWS_ADMIN_GROUPS"
207211
)
@@ -234,6 +238,17 @@ class IncidentFeatureSettings(BaseSettings):
234238
)
235239

236240

241+
class SreOpsSettings(BaseSettings):
242+
"""SRE Ops configuration settings."""
243+
244+
SRE_OPS_CHANNEL_ID: str = Field(default="", alias="SRE_OPS_CHANNEL_ID")
245+
model_config = SettingsConfigDict(
246+
env_file=".env",
247+
case_sensitive=True,
248+
extra="ignore",
249+
)
250+
251+
237252
class ServerSettings(BaseSettings):
238253
"""Server configuration settings."""
239254

@@ -319,6 +334,7 @@ class Settings(BaseSettings):
319334
reports: ReportsSettings
320335
aws_feature: AWSFeatureSettings
321336
feat_incident: IncidentFeatureSettings
337+
sre_ops: SreOpsSettings
322338

323339
# Development settings
324340
dev: DevSettings
@@ -345,6 +361,7 @@ def __init__(self, **kwargs):
345361
"reports": ReportsSettings,
346362
"aws_feature": AWSFeatureSettings,
347363
"feat_incident": IncidentFeatureSettings,
364+
"sre_ops": SreOpsSettings,
348365
"dev": DevSettings,
349366
}
350367

app/integrations/slack/client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from slack_sdk import WebClient
2+
from core.config import settings
3+
4+
5+
class SlackClientManager:
6+
"""Manages the Slack API client. Ensures a single instance is used throughout the application."""
7+
8+
_client = None
9+
10+
@classmethod
11+
def get_client(cls) -> WebClient:
12+
"""Returns a singleton instance of the Slack WebClient."""
13+
if cls._client is None:
14+
cls._client = WebClient(token=settings.slack.SLACK_TOKEN)
15+
return cls._client

app/jobs/revoke_aws_sso_access.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from modules.aws import aws_access_requests
2-
from integrations.aws import sso_admin, identity_store
3-
from server.utils import log_ops_message
41
from core.logging import get_module_logger
2+
from integrations.aws import identity_store, sso_admin
3+
from modules.aws import aws_access_requests
4+
from modules.ops.notifications import log_ops_message
55

66
logger = get_module_logger()
77

@@ -37,7 +37,7 @@ def revoke_aws_sso_access(client):
3737
user=user_id,
3838
text=msg,
3939
)
40-
log_ops_message(client, msg)
40+
log_ops_message(msg)
4141

4242
except Exception as e:
4343
logger.error(

app/modules/aws/aws_access_requests.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import boto3 # type: ignore
21
import datetime
32
import uuid
43

5-
from slack_sdk import WebClient
6-
from server.utils import log_ops_message
4+
import boto3 # type: ignore
75
from core.config import settings
86
from integrations.aws import identity_store, organizations, sso_admin
9-
7+
from modules.ops.notifications import log_ops_message
8+
from slack_sdk import WebClient
109

1110
PREFIX = settings.PREFIX
1211

@@ -200,7 +199,7 @@ def access_view_handler(ack, body, logger, client: WebClient):
200199
access_type=access_type,
201200
rationale=rationale,
202201
)
203-
log_ops_message(client, msg)
202+
log_ops_message(msg)
204203
aws_user_id = identity_store.get_user_id(email)
205204

206205
if aws_user_id is None:

app/modules/ops/notifications.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from slack_sdk.errors import SlackApiError
2+
from core.config import settings
3+
from core.logging import get_module_logger
4+
from integrations.slack.client import SlackClientManager
5+
6+
OPS_CHANNEL_ID = settings.sre_ops.SRE_OPS_CHANNEL_ID
7+
8+
logger = get_module_logger()
9+
10+
11+
def log_ops_message(message: str):
12+
"""Provides a standardized way to log operational messages to a specific Slack channel configured in the settings.
13+
Failure to log the message will not raise an exception, but will be logged in the application logs to avoid disrupting the main application flow.
14+
15+
Args:
16+
message (str): The message to be logged to the operations channel.
17+
18+
Returns:
19+
None
20+
"""
21+
client = SlackClientManager.get_client()
22+
if not client:
23+
logger.error("slack_client_not_initialized")
24+
return
25+
if not OPS_CHANNEL_ID:
26+
logger.warning("ops_channel_id_not_configured")
27+
return
28+
channel_id = OPS_CHANNEL_ID
29+
logger.info("ops_message_logged", message=message)
30+
try:
31+
client.conversations_join(channel=channel_id)
32+
client.chat_postMessage(channel=channel_id, text=message, as_user=True)
33+
except SlackApiError as e:
34+
logger.error("ops_message_failed", message=message, error=str(e))
File renamed without changes.

0 commit comments

Comments
 (0)