@liflig/cdk 3.22.19 → 3.23.0
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.
- package/assets/README.md +32 -0
- package/assets/slack-error-log-handler-lambda/.python-version +1 -0
- package/assets/slack-error-log-handler-lambda/__pycache__/index.cpython-314.pyc +0 -0
- package/assets/slack-error-log-handler-lambda/__pycache__/test_handler.cpython-314-pytest-9.0.2.pyc +0 -0
- package/assets/slack-error-log-handler-lambda/index.py +411 -0
- package/assets/slack-error-log-handler-lambda/pyproject.toml +13 -0
- package/assets/slack-error-log-handler-lambda/test_handler.py +229 -0
- package/assets/slack-error-log-handler-lambda/uv.lock +176 -0
- package/lib/alarms/database-alarms.d.ts +31 -18
- package/lib/alarms/database-alarms.js +39 -18
- package/lib/alarms/index.d.ts +2 -0
- package/lib/alarms/index.js +2 -1
- package/lib/alarms/lambda-alarms.d.ts +116 -14
- package/lib/alarms/lambda-alarms.js +158 -29
- package/lib/alarms/log-filter-patterns.d.ts +5 -0
- package/lib/alarms/log-filter-patterns.js +6 -0
- package/lib/alarms/queue-alarms.d.ts +88 -0
- package/lib/alarms/queue-alarms.js +109 -0
- package/lib/alarms/service-alarms.d.ts +75 -8
- package/lib/alarms/service-alarms.js +130 -38
- package/lib/alarms/slack-alarm.d.ts +6 -1
- package/lib/alarms/slack-alarm.js +23 -5
- package/lib/ecs/fargate-service.d.ts +111 -1
- package/lib/ecs/fargate-service.js +46 -2
- package/lib/load-balancer/load-balancer.js +2 -2
- package/lib/rds/database.d.ts +83 -0
- package/lib/rds/database.js +41 -1
- package/package.json +1 -1
package/assets/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
**Lambda assets testing convention**
|
|
2
|
+
|
|
3
|
+
Each lambda under `assets/` can include a small test setup and development dependencies. To make tests reproducible and isolated we follow this convention:
|
|
4
|
+
|
|
5
|
+
Each lambda that has tests exposes a `pyproject.toml` declaring its test/dev dependencies.
|
|
6
|
+
- Tests are inside the lambda folder (e.g. `assets/slack-error-log-handler-lambda/test_handler.py`).
|
|
7
|
+
- To run all lambda tests locally or in CI, run the project Makefile target:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
make py-test
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This target will auto-discover all `assets/*` directories that contain tests (via a `pyproject.toml`), and run `pytest` in each folder using the interpreter(s) provided on your machine or CI runner.
|
|
14
|
+
|
|
15
|
+
***Interpreter selection***
|
|
16
|
+
|
|
17
|
+
- The repository declares supported Python interpreters in the top-level `mise.toml` (used by CI).
|
|
18
|
+
- Each asset should include a `.python-version` containing the desired interpreter for tests (for example `3.14`).
|
|
19
|
+
- If present, the test runner will prefer that interpreter; if the interpreter isn't available on the runner the CI job will fail (this helps surface missing interpreter versions early).
|
|
20
|
+
|
|
21
|
+
Per-lambda `pyproject.toml` keeps each lambda's test/dev dependencies isolated and avoids clashes across assets. Tests run using the interpreter installed on the runner (see `mise.toml` and CI configuration).
|
|
22
|
+
- boto3 should match the version used in the lambda runtime - https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html#python-sdk-included
|
|
23
|
+
- If a lambda requires system packages or compiled native extensions, CI needs to provide those system dependencies (e.g., apt packages) or you should run tests inside a suitable container.
|
|
24
|
+
|
|
25
|
+
***Adding a new lambda with tests***
|
|
26
|
+
|
|
27
|
+
1. Add your lambda folder under `assets/`.
|
|
28
|
+
2. Add `pyproject.toml` declaring your test/dev dependencies.
|
|
29
|
+
3. Add test files inside the folder and ensure they import the lambda code relative to that folder.
|
|
30
|
+
4. Run `make py-test` locally to verify.
|
|
31
|
+
|
|
32
|
+
This convention keeps test runs predictable and easy to run both locally and in CI.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
package/assets/slack-error-log-handler-lambda/__pycache__/test_handler.cpython-314-pytest-9.0.2.pyc
ADDED
|
Binary file
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slack error log handler Lambda
|
|
3
|
+
=============================
|
|
4
|
+
Lambda used as a CloudWatch Logs subscription destination. It
|
|
5
|
+
decodes base64+gzip subscription payloads, extracts structured JSON log
|
|
6
|
+
messages and posts a compact Slack message using a webhook URL
|
|
7
|
+
stored in Secrets Manager.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pprint import pprint
|
|
13
|
+
from typing import TypedDict, Optional
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
import base64
|
|
16
|
+
import gzip
|
|
17
|
+
from urllib.error import URLError, HTTPError
|
|
18
|
+
import time
|
|
19
|
+
import boto3
|
|
20
|
+
|
|
21
|
+
# Module-level cached Secrets Manager client. Stored as a global variable and
|
|
22
|
+
# lazily created by _get_secrets_client when a client is not injected.
|
|
23
|
+
_secrets_client = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Lazily-created cached Secrets Manager client. Don't create a boto3 client at
|
|
27
|
+
# import time (it may raise NoRegionError during test import or create network
|
|
28
|
+
# noise). Use _get_secrets_client() to obtain a client (injectable for tests).
|
|
29
|
+
def _get_secrets_client(secrets_client=None):
|
|
30
|
+
"""Return a Secrets Manager client.
|
|
31
|
+
|
|
32
|
+
Caches the client lazily, so it isn’t created during import,
|
|
33
|
+
but is reused across invocations within the same Lambda execution environment.
|
|
34
|
+
|
|
35
|
+
Behavior:
|
|
36
|
+
- If a `secrets_client` is provided, return it directly and do not mutate
|
|
37
|
+
any cache. This is the injection path used by tests.
|
|
38
|
+
- Otherwise, lazily create and cache a boto3 client and return it.
|
|
39
|
+
"""
|
|
40
|
+
if secrets_client is not None:
|
|
41
|
+
return secrets_client
|
|
42
|
+
|
|
43
|
+
global _secrets_client
|
|
44
|
+
if _secrets_client is None:
|
|
45
|
+
_secrets_client = boto3.client("secretsmanager")
|
|
46
|
+
|
|
47
|
+
return _secrets_client
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SLACK_URL_SECRET_NAME = os.getenv("SLACK_URL_SECRET_NAME", None)
|
|
51
|
+
PROJECT_NAME = os.getenv("PROJECT_NAME", "undefined")
|
|
52
|
+
ENVIRONMENT_NAME = os.getenv("ENVIRONMENT_NAME", "undefined")
|
|
53
|
+
REGION = os.getenv("AWS_REGION", "eu-west-1")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CloudWatchLog(TypedDict, total=False):
|
|
57
|
+
"""Single parsed log entry (message, stack_trace, service)."""
|
|
58
|
+
|
|
59
|
+
message: str
|
|
60
|
+
stack_trace: Optional[str]
|
|
61
|
+
service: Optional[str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CloudWatchEvent(TypedDict):
|
|
65
|
+
"""Decoded CloudWatch Logs subscription payload."""
|
|
66
|
+
|
|
67
|
+
logEvents: list[CloudWatchLog]
|
|
68
|
+
logGroup: str
|
|
69
|
+
logStream: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def handler(event, _context):
|
|
73
|
+
"""Entrypoint that delegates to `process_event`."""
|
|
74
|
+
return process_event(event, _context)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def process_event(
|
|
78
|
+
event,
|
|
79
|
+
_context,
|
|
80
|
+
*,
|
|
81
|
+
secrets_client=None,
|
|
82
|
+
urlopen_func=None,
|
|
83
|
+
time_func=time.time,
|
|
84
|
+
slack_secret_name=SLACK_URL_SECRET_NAME,
|
|
85
|
+
project_name=PROJECT_NAME,
|
|
86
|
+
environment_name=ENVIRONMENT_NAME,
|
|
87
|
+
region=REGION,
|
|
88
|
+
):
|
|
89
|
+
"""Decode a CloudWatch Logs event, build a Slack payload and post it.
|
|
90
|
+
|
|
91
|
+
Network and secrets access are injectable for testing.
|
|
92
|
+
"""
|
|
93
|
+
pprint("Dump: " + json.dumps(event))
|
|
94
|
+
print(f"boto3 version: {boto3.__version__}")
|
|
95
|
+
|
|
96
|
+
data = event["awslogs"]["data"]
|
|
97
|
+
decoded_message: CloudWatchEvent = json.loads(
|
|
98
|
+
gzip.decompress(base64.b64decode(data))
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
pprint("Data: " + json.dumps(decoded_message))
|
|
102
|
+
|
|
103
|
+
timestamp_in_seconds = _resolve_timestamp(
|
|
104
|
+
decoded_message.get("logEvents", []), time_func
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
log_group = decoded_message.get("logGroup", "undefined")
|
|
108
|
+
|
|
109
|
+
# Use the provided region (default is module-level REGION)
|
|
110
|
+
resolved_region = region
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if len(decoded_message["logEvents"]) > 0:
|
|
114
|
+
log_events: list[CloudWatchLog] = [
|
|
115
|
+
json.loads(log_event["message"])
|
|
116
|
+
for log_event in decoded_message["logEvents"]
|
|
117
|
+
]
|
|
118
|
+
slack_message = create_slack_message_from_cloudwatch_log(
|
|
119
|
+
log_events,
|
|
120
|
+
log_group,
|
|
121
|
+
timestamp_in_seconds,
|
|
122
|
+
project_name=project_name,
|
|
123
|
+
environment_name=environment_name,
|
|
124
|
+
region=resolved_region,
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
slack_message = create_slack_message(
|
|
128
|
+
"No log message received in slack error log handler",
|
|
129
|
+
project_name,
|
|
130
|
+
timestamp_in_seconds,
|
|
131
|
+
environment_name,
|
|
132
|
+
f"No log messages received when the lambda handling errors for log group {log_group} was called.",
|
|
133
|
+
None,
|
|
134
|
+
log_group,
|
|
135
|
+
[],
|
|
136
|
+
)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
slack_message = create_slack_message(
|
|
139
|
+
f"Error in {log_group}",
|
|
140
|
+
project_name,
|
|
141
|
+
timestamp_in_seconds,
|
|
142
|
+
environment_name,
|
|
143
|
+
decoded_message["logEvents"][0]["message"][:750] + "...\n...",
|
|
144
|
+
None,
|
|
145
|
+
log_group,
|
|
146
|
+
[f"{evt['message'][:100]}..." for evt in decoded_message["logEvents"][1:]],
|
|
147
|
+
)
|
|
148
|
+
send_slack_notification(
|
|
149
|
+
slack_message,
|
|
150
|
+
secrets_client=secrets_client,
|
|
151
|
+
urlopen_func=urlopen_func,
|
|
152
|
+
slack_secret_name=slack_secret_name,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_secret(secret, secrets_client=None):
|
|
157
|
+
"""Return a secret string from Secrets Manager."""
|
|
158
|
+
client = _get_secrets_client(secrets_client)
|
|
159
|
+
try:
|
|
160
|
+
return client.get_secret_value(SecretId=secret)["SecretString"]
|
|
161
|
+
except Exception as e:
|
|
162
|
+
raise RuntimeError(f"Error retrieving secret '{secret}': {e}") from e
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def send_slack_notification(
|
|
166
|
+
slack_message: dict,
|
|
167
|
+
*,
|
|
168
|
+
secrets_client=None,
|
|
169
|
+
urlopen_func=None,
|
|
170
|
+
slack_secret_name=None,
|
|
171
|
+
):
|
|
172
|
+
"""Post the Slack payload using a webhook from Secrets Manager."""
|
|
173
|
+
client = _get_secrets_client(secrets_client)
|
|
174
|
+
if urlopen_func is None:
|
|
175
|
+
urlopen_func = urlopen
|
|
176
|
+
if slack_secret_name is None:
|
|
177
|
+
slack_secret_name = SLACK_URL_SECRET_NAME
|
|
178
|
+
|
|
179
|
+
slack_url = get_secret(slack_secret_name, secrets_client=client)
|
|
180
|
+
|
|
181
|
+
# Use helper to post so error mapping is consistent
|
|
182
|
+
_post_to_slack(slack_url, slack_message, urlopen_func=urlopen_func)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _post_to_slack(url: str, payload: dict, *, urlopen_func):
|
|
186
|
+
req = Request(
|
|
187
|
+
url,
|
|
188
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
189
|
+
headers={"Content-Type": "application/json"},
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
urlopen_func(req).read()
|
|
193
|
+
except HTTPError as e:
|
|
194
|
+
raise RuntimeError(f"Request to slack failed: {e.code} {e.reason}") from e
|
|
195
|
+
except URLError as e:
|
|
196
|
+
raise RuntimeError(f"Server connection to slack failed: {e.reason}") from e
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _resolve_timestamp(log_events, time_func):
|
|
200
|
+
"""Return a unix timestamp in seconds derived from the first log event.
|
|
201
|
+
|
|
202
|
+
Falls back to the current time on malformed values.
|
|
203
|
+
"""
|
|
204
|
+
current_timestamp = int(time_func())
|
|
205
|
+
if not log_events:
|
|
206
|
+
return current_timestamp
|
|
207
|
+
raw = log_events[0].get("timestamp", current_timestamp)
|
|
208
|
+
try:
|
|
209
|
+
ts = int(raw)
|
|
210
|
+
except Exception:
|
|
211
|
+
return current_timestamp
|
|
212
|
+
# CloudWatch may return milliseconds; normalise to seconds when value is large.
|
|
213
|
+
return ts if ts < 1_000_000_000_000 else int(ts / 1000)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_masked_slack_webhook_url(slack_webhook_url: str) -> str:
|
|
217
|
+
"""Return a masked representation of the webhook URL suitable for logging.
|
|
218
|
+
|
|
219
|
+
Keeps the leading part of the URL intact and replaces the final path
|
|
220
|
+
segment with the same number of asterisks. Examples:
|
|
221
|
+
|
|
222
|
+
- "https://example.com/abcd" -> "https://example.com/****"
|
|
223
|
+
- "tokenonly" -> "*****"
|
|
224
|
+
"""
|
|
225
|
+
if not slack_webhook_url:
|
|
226
|
+
return ""
|
|
227
|
+
trimmed = slack_webhook_url.rstrip("/")
|
|
228
|
+
head, sep, tail = trimmed.rpartition("/")
|
|
229
|
+
if sep == "":
|
|
230
|
+
# no slash present; mask the whole value
|
|
231
|
+
return "*" * len(tail)
|
|
232
|
+
return head + sep + ("*" * len(tail))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def create_slack_message_from_cloudwatch_log(
|
|
236
|
+
events: list[CloudWatchLog],
|
|
237
|
+
log_group,
|
|
238
|
+
timestamp,
|
|
239
|
+
*,
|
|
240
|
+
project_name: str = PROJECT_NAME,
|
|
241
|
+
environment_name: str = ENVIRONMENT_NAME,
|
|
242
|
+
region: Optional[str] = None,
|
|
243
|
+
):
|
|
244
|
+
"""Create a Slack message from parsed CloudWatch log events.
|
|
245
|
+
|
|
246
|
+
`project_name` and `environment_name` default to the module-level
|
|
247
|
+
env-vars but can be provided for testing or when the runtime should
|
|
248
|
+
override them.
|
|
249
|
+
"""
|
|
250
|
+
event = events[0]
|
|
251
|
+
|
|
252
|
+
service = event.get("service", "Undefined")
|
|
253
|
+
stack_trace = event.get("stack_trace")
|
|
254
|
+
message = event.get("message", "No message.")
|
|
255
|
+
|
|
256
|
+
other_messages = [
|
|
257
|
+
other_event.get("message", "undefined") for other_event in events[1:]
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
slack_message = create_slack_message(
|
|
261
|
+
f"{service} error",
|
|
262
|
+
project_name,
|
|
263
|
+
timestamp,
|
|
264
|
+
environment_name,
|
|
265
|
+
message,
|
|
266
|
+
stack_trace,
|
|
267
|
+
log_group,
|
|
268
|
+
other_messages,
|
|
269
|
+
region=region,
|
|
270
|
+
)
|
|
271
|
+
return slack_message
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def create_slack_message(
|
|
275
|
+
title,
|
|
276
|
+
project,
|
|
277
|
+
timestamp,
|
|
278
|
+
environment,
|
|
279
|
+
message,
|
|
280
|
+
stack_trace,
|
|
281
|
+
log_group,
|
|
282
|
+
extra_messages: list[str],
|
|
283
|
+
*,
|
|
284
|
+
region: Optional[str] = None,
|
|
285
|
+
):
|
|
286
|
+
"""Construct the Slack blocks payload used by the notification."""
|
|
287
|
+
blocks = [
|
|
288
|
+
{
|
|
289
|
+
"type": "header",
|
|
290
|
+
"text": {
|
|
291
|
+
"type": "plain_text",
|
|
292
|
+
"text": f"⚠️{title}⚠️",
|
|
293
|
+
"emoji": True,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": "*Message*:"}},
|
|
297
|
+
{
|
|
298
|
+
"type": "rich_text",
|
|
299
|
+
"elements": [
|
|
300
|
+
{
|
|
301
|
+
"type": "rich_text_preformatted",
|
|
302
|
+
"elements": [{"type": "text", "text": f"{message}"}],
|
|
303
|
+
"border": 0,
|
|
304
|
+
}
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
if stack_trace:
|
|
310
|
+
blocks.extend(
|
|
311
|
+
[
|
|
312
|
+
{
|
|
313
|
+
"type": "section",
|
|
314
|
+
"text": {"type": "mrkdwn", "text": "*Stack trace*:"},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"type": "rich_text",
|
|
318
|
+
"elements": [
|
|
319
|
+
{
|
|
320
|
+
"type": "rich_text_preformatted",
|
|
321
|
+
"elements": [
|
|
322
|
+
{"type": "text", "text": f"{stack_trace[:750]}"}
|
|
323
|
+
],
|
|
324
|
+
"border": 0,
|
|
325
|
+
}
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
]
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
use_region = region or REGION
|
|
332
|
+
blocks.extend(
|
|
333
|
+
[
|
|
334
|
+
{
|
|
335
|
+
"type": "section",
|
|
336
|
+
"text": {
|
|
337
|
+
"type": "mrkdwn",
|
|
338
|
+
"text": f"<https://{use_region}.console.aws.amazon.com/cloudwatch/home?region={use_region}#logsV2:logs-insights$3FqueryDetail$3D~(end~0~start~-1800~timeType~'RELATIVE~tz~'LOCAL~unit~'seconds~editorString~'fields*20timestamp*2c*20message*0a*7c*20filter*20level*20*3d*20*22ERROR*22*0a*7c*20sort*20timestamp*20desc*0a*7c*20limit*20100~source~(~'{log_group})~lang~'CWLI)|Logs insights>",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{"type": "divider"},
|
|
342
|
+
{
|
|
343
|
+
"type": "context",
|
|
344
|
+
"elements": [
|
|
345
|
+
{
|
|
346
|
+
"type": "mrkdwn",
|
|
347
|
+
"text": "*Logged:* "
|
|
348
|
+
+ f"<!date^{timestamp}^"
|
|
349
|
+
+ "{date_num} {time_secs}|Failed parsing timestamp>",
|
|
350
|
+
},
|
|
351
|
+
{"type": "mrkdwn", "text": f"*Project:* {project}"},
|
|
352
|
+
{"type": "mrkdwn", "text": f"*Environment:* {environment}"},
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if len(extra_messages) > 0:
|
|
359
|
+
list_items = [
|
|
360
|
+
{
|
|
361
|
+
"type": "rich_text_section",
|
|
362
|
+
"elements": [
|
|
363
|
+
{
|
|
364
|
+
"type": "text",
|
|
365
|
+
"text": message,
|
|
366
|
+
"style": {"italic": True},
|
|
367
|
+
}
|
|
368
|
+
],
|
|
369
|
+
}
|
|
370
|
+
for message in extra_messages[:3]
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
if len(extra_messages) > 3:
|
|
374
|
+
list_items.append(
|
|
375
|
+
{
|
|
376
|
+
"type": "rich_text_section",
|
|
377
|
+
"elements": [
|
|
378
|
+
{
|
|
379
|
+
"type": "text",
|
|
380
|
+
"text": f"...and {len(extra_messages[3:])} other messages.",
|
|
381
|
+
"style": {"italic": True},
|
|
382
|
+
}
|
|
383
|
+
],
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
blocks.extend(
|
|
388
|
+
[
|
|
389
|
+
{"type": "divider"},
|
|
390
|
+
{
|
|
391
|
+
"type": "section",
|
|
392
|
+
"text": {
|
|
393
|
+
"type": "mrkdwn",
|
|
394
|
+
"text": f"*And {len(extra_messages)} other logs with messages:*",
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"type": "rich_text",
|
|
399
|
+
"elements": [
|
|
400
|
+
{
|
|
401
|
+
"type": "rich_text_list",
|
|
402
|
+
"style": "bullet",
|
|
403
|
+
"indent": 0,
|
|
404
|
+
"elements": list_items,
|
|
405
|
+
}
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
]
|
|
409
|
+
)
|
|
410
|
+
slack_template = {"blocks": blocks}
|
|
411
|
+
return slack_template
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "slack-error-log-handler-lambda"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
requires-python = ">=3.14"
|
|
5
|
+
|
|
6
|
+
[dependency-groups]
|
|
7
|
+
dev = [
|
|
8
|
+
"pytest>=7.0",
|
|
9
|
+
"pytest-mock>=3.0",
|
|
10
|
+
# boto3 should match the version used in the lambda runtime
|
|
11
|
+
# https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html#python-sdk-included
|
|
12
|
+
"boto3>=1.26",
|
|
13
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import gzip
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
os.environ.setdefault("AWS_REGION", "eu-west-1")
|
|
8
|
+
|
|
9
|
+
import index as handler_module
|
|
10
|
+
|
|
11
|
+
from index import (
|
|
12
|
+
create_slack_message_from_cloudwatch_log,
|
|
13
|
+
process_event,
|
|
14
|
+
get_secret,
|
|
15
|
+
send_slack_notification,
|
|
16
|
+
get_masked_slack_webhook_url,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_event(log_messages):
|
|
21
|
+
log_events = []
|
|
22
|
+
for i, msg in enumerate(log_messages, start=1):
|
|
23
|
+
log_events.append(
|
|
24
|
+
{
|
|
25
|
+
"id": str(i),
|
|
26
|
+
"timestamp": 1620000000000 + i,
|
|
27
|
+
"message": json.dumps(msg),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
payload = {
|
|
31
|
+
"logGroup": "test-log-group",
|
|
32
|
+
"logStream": "test-log-stream",
|
|
33
|
+
"logEvents": log_events,
|
|
34
|
+
}
|
|
35
|
+
compressed = base64.b64encode(
|
|
36
|
+
gzip.compress(json.dumps(payload).encode("utf-8"))
|
|
37
|
+
).decode("utf-8")
|
|
38
|
+
return {"awslogs": {"data": compressed}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Small helper used across tests to represent a secrets client that returns
|
|
42
|
+
# a fixed secret string.
|
|
43
|
+
class SimpleSecretsClient:
|
|
44
|
+
def __init__(self, secret_str):
|
|
45
|
+
self._secret = secret_str
|
|
46
|
+
|
|
47
|
+
def get_secret_value(self, SecretId):
|
|
48
|
+
return {"SecretString": self._secret}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def dummy_resp():
|
|
53
|
+
class _D:
|
|
54
|
+
def read(self):
|
|
55
|
+
return b"ok"
|
|
56
|
+
|
|
57
|
+
return _D()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_create_slack_message_from_cloudwatch_log_defaults():
|
|
61
|
+
events = [{"service": "svc", "message": "err", "stack_trace": "trace"}]
|
|
62
|
+
slack = create_slack_message_from_cloudwatch_log(
|
|
63
|
+
events, "lg", 123456, project_name="proj", environment_name="env"
|
|
64
|
+
)
|
|
65
|
+
assert "blocks" in slack
|
|
66
|
+
# header contains the title with service
|
|
67
|
+
header_text = slack["blocks"][0]["text"]["text"]
|
|
68
|
+
assert "svc error" in header_text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_get_secret_success_and_failure():
|
|
72
|
+
assert (
|
|
73
|
+
get_secret("id", secrets_client=SimpleSecretsClient("super-secret"))
|
|
74
|
+
== "super-secret"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
class BrokenSecretsClient:
|
|
78
|
+
def get_secret_value(self, SecretId):
|
|
79
|
+
raise Exception("boom")
|
|
80
|
+
|
|
81
|
+
with pytest.raises(Exception) as exc:
|
|
82
|
+
get_secret("id", secrets_client=BrokenSecretsClient())
|
|
83
|
+
assert "Error retrieving secret" in str(exc.value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_send_slack_notification_uses_secret_and_urlopen(dummy_resp):
|
|
87
|
+
# send_slack_notification uses get_secret and urlopen; inject both
|
|
88
|
+
dummy_client = SimpleSecretsClient("https://hooks.slack.com/services/T/B/S")
|
|
89
|
+
|
|
90
|
+
called = {"c": 0}
|
|
91
|
+
|
|
92
|
+
def dummy_urlopen(req):
|
|
93
|
+
called["c"] += 1
|
|
94
|
+
# basic checks on request body (headers may not be accessible in this
|
|
95
|
+
# test environment in the same way across Python versions).
|
|
96
|
+
data = None
|
|
97
|
+
if hasattr(req, "data") and req.data is not None:
|
|
98
|
+
data = req.data
|
|
99
|
+
elif hasattr(req, "get_data"):
|
|
100
|
+
data = req.get_data()
|
|
101
|
+
assert data is not None
|
|
102
|
+
payload = json.loads(data.decode("utf-8"))
|
|
103
|
+
assert payload.get("foo") == "bar"
|
|
104
|
+
return dummy_resp
|
|
105
|
+
|
|
106
|
+
send_slack_notification(
|
|
107
|
+
{"foo": "bar"},
|
|
108
|
+
secrets_client=dummy_client,
|
|
109
|
+
urlopen_func=dummy_urlopen,
|
|
110
|
+
slack_secret_name="unused",
|
|
111
|
+
)
|
|
112
|
+
assert called["c"] == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_create_slack_message_more_than_three_events():
|
|
116
|
+
# 5 events -> summary should say "...and 2 other messages." or similar
|
|
117
|
+
events = [
|
|
118
|
+
{"service": "svc1", "message": "m1", "stack_trace": "t1"},
|
|
119
|
+
{"service": "svc2", "message": "m2", "stack_trace": "t2"},
|
|
120
|
+
{"service": "svc3", "message": "m3", "stack_trace": "t3"},
|
|
121
|
+
{"service": "svc4", "message": "m4", "stack_trace": "t4"},
|
|
122
|
+
{"service": "svc5", "message": "m5", "stack_trace": "t5"},
|
|
123
|
+
]
|
|
124
|
+
slack = create_slack_message_from_cloudwatch_log(
|
|
125
|
+
events, "lg", 123456, project_name="proj", environment_name="env"
|
|
126
|
+
)
|
|
127
|
+
# Collect text from blocks and assert the summary mentions extra messages
|
|
128
|
+
text_join = " ".join(
|
|
129
|
+
b.get("text", {}).get("text", "")
|
|
130
|
+
for b in slack.get("blocks", [])
|
|
131
|
+
if "text" in b
|
|
132
|
+
)
|
|
133
|
+
# Be permissive about the exact wording; the handler may say "And 4 other logs"
|
|
134
|
+
# or similar. Ensure some "other" summary appears.
|
|
135
|
+
assert "other" in text_join.lower()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_create_slack_message_no_stack_trace():
|
|
139
|
+
# event without stack_trace should include a fallback such as "No stack trace."
|
|
140
|
+
events = [{"service": "svc", "message": "err"}] # no stack_trace key
|
|
141
|
+
slack = create_slack_message_from_cloudwatch_log(
|
|
142
|
+
events, "lg", 123456, project_name="proj", environment_name="env"
|
|
143
|
+
)
|
|
144
|
+
# Assert there is no section block that contains the explicit
|
|
145
|
+
# "*Stack trace*:" header and that no rich_text block for stack trace
|
|
146
|
+
# exists.
|
|
147
|
+
blocks = slack.get("blocks", [])
|
|
148
|
+
assert not any(
|
|
149
|
+
b.get("type") == "section"
|
|
150
|
+
and "stack trace" in b.get("text", {}).get("text", "").lower()
|
|
151
|
+
for b in blocks
|
|
152
|
+
)
|
|
153
|
+
# Ensure at least the message rich_text block exists (the handler
|
|
154
|
+
# always includes a rich_text block for the message itself).
|
|
155
|
+
assert any(b.get("type") == "rich_text" for b in blocks)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_injected_client_does_not_pollute_cache(monkeypatch):
|
|
159
|
+
# Start from a clean module-level cache
|
|
160
|
+
monkeypatch.setattr(handler_module, "_secrets_client", None, raising=False)
|
|
161
|
+
|
|
162
|
+
injected = object()
|
|
163
|
+
assert handler_module._get_secrets_client(secrets_client=injected) is injected
|
|
164
|
+
# cache should remain empty after injection
|
|
165
|
+
assert handler_module._secrets_client is None
|
|
166
|
+
|
|
167
|
+
# Lazy-create a real client via boto3.client (monkeypatch to a sentinel)
|
|
168
|
+
monkeypatch.setattr(handler_module.boto3, "client", lambda svc: "sentinel")
|
|
169
|
+
assert handler_module._get_secrets_client() == "sentinel"
|
|
170
|
+
assert handler_module._secrets_client == "sentinel"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_process_event_json_decode_error_fallback(dummy_resp):
|
|
174
|
+
# Build compressed payload with a raw non-JSON first message to trigger
|
|
175
|
+
# JSONDecodeError in the parser path.
|
|
176
|
+
payload = {
|
|
177
|
+
"logGroup": "test-log-group",
|
|
178
|
+
"logStream": "test-log-stream",
|
|
179
|
+
"logEvents": [
|
|
180
|
+
{"id": "1", "timestamp": 1620000000000, "message": "not-a-json"},
|
|
181
|
+
{
|
|
182
|
+
"id": "2",
|
|
183
|
+
"timestamp": 1620000000001,
|
|
184
|
+
"message": json.dumps({"service": "svc2", "message": "m2"}),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
compressed = base64.b64encode(
|
|
189
|
+
gzip.compress(json.dumps(payload).encode("utf-8"))
|
|
190
|
+
).decode("utf-8")
|
|
191
|
+
ev = {"awslogs": {"data": compressed}}
|
|
192
|
+
|
|
193
|
+
dummy_client = SimpleSecretsClient("https://hooks.slack.com/services/T/B/S")
|
|
194
|
+
|
|
195
|
+
called = {"c": 0}
|
|
196
|
+
|
|
197
|
+
def dummy_urlopen(req):
|
|
198
|
+
called["c"] += 1
|
|
199
|
+
return dummy_resp
|
|
200
|
+
|
|
201
|
+
# Should not raise and should call urlopen once
|
|
202
|
+
process_event(ev, None, secrets_client=dummy_client, urlopen_func=dummy_urlopen)
|
|
203
|
+
assert called["c"] == 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_process_event_with_broken_secrets_client_raises():
|
|
207
|
+
ev = make_event([{"service": "svc", "message": "oops", "stack_trace": "trace"}])
|
|
208
|
+
|
|
209
|
+
class BrokenSecretsClient:
|
|
210
|
+
def get_secret_value(self, SecretId):
|
|
211
|
+
raise Exception("boom-secret")
|
|
212
|
+
|
|
213
|
+
with pytest.raises(Exception):
|
|
214
|
+
process_event(
|
|
215
|
+
ev, None, secrets_client=BrokenSecretsClient(), urlopen_func=lambda r: None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.parametrize(
|
|
220
|
+
"url, tail",
|
|
221
|
+
[
|
|
222
|
+
("https://hooks.slack.com/services/T/B/S", "S"),
|
|
223
|
+
("https://example.com/abcd/", "abcd"),
|
|
224
|
+
("https://example.com/foo", "foo"),
|
|
225
|
+
],
|
|
226
|
+
)
|
|
227
|
+
def test_get_masked_slack_webhook_url(url, tail):
|
|
228
|
+
masked = get_masked_slack_webhook_url(url)
|
|
229
|
+
assert masked.endswith("*" * len(tail))
|