diff --git a/cdk/common.py b/cdk/common.py index 5dec302..f4b4be3 100644 --- a/cdk/common.py +++ b/cdk/common.py @@ -73,7 +73,36 @@ def add_ddb_alarms(scope, id_prefix, table, alarm_name_prefix, alarm_topic): ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) -def add_sender_auth_rejected_alarm(scope, id_prefix, function_name, alarm_topic): +def make_function_log_group(scope, id_prefix, function_name): + """Explicit log group for a Lambda, replacing the deprecated + ``log_retention`` prop (INFRA-114). + + Making the group a real stack resource (a) drops the LogRetention custom + resource whose role carried wildcard ``logs:PutRetentionPolicy`` (checkov + CKV_AWS_111), and (b) makes the group an orderable CFN dependency: + consumers like the sender-auth metric filter now deploy AFTER the group + exists. The previous by-name import raced group creation in a fresh + account and failed the first seahaven-prod deploy (the mgmt account + masked this because its groups predated the filter). + + RETAIN matches the repo convention for stateful resources and mirrors the + old behavior (LogRetention never deleted groups on stack delete). NOTE: + in an account where ``/aws/lambda/`` already exists out-of-band + (mgmt), deploying this CREATE would collide -- acceptable because mgmt is + frozen post-migration and never redeployed from main. + """ + return logs.LogGroup( + scope, + f"{id_prefix}LogGroup", + log_group_name=f"/aws/lambda/{function_name}", + retention=logs.RetentionDays.TWO_MONTHS, + removal_policy=RemovalPolicy.RETAIN, + ) + + +def add_sender_auth_rejected_alarm( + scope, id_prefix, function_name, alarm_topic, log_group +): """Metric-filter + alarm on ``sender_auth_rejected`` warnings (INFRA-107). A rejected inbound email is skipped without erroring the invocation, so it @@ -84,21 +113,18 @@ def add_sender_auth_rejected_alarm(scope, id_prefix, function_name, alarm_topic) mail while the pipeline reports healthy. ALARM-only SnsAction to site-alerts; no OK action. The metric filter reads - the function's own log group (imported by the deterministic - ``/aws/lambda/`` name, created by the function's log_retention). A plain - substring pattern is used because Lambda prefixes each line with its own - level/timestamp/request-id, so the JSON payload is not a standalone JSON - log event a `{$.event=...}` pattern could match. + the function's own log group, passed in as the EXPLICIT LogGroup construct + (from ``make_function_log_group``) so CFN orders the filter after the + group exists -- a by-name import here failed the first fresh-account + deploy. A plain substring pattern is used because Lambda prefixes each + line with its own level/timestamp/request-id, so the JSON payload is not + a standalone JSON log event a `{$.event=...}` pattern could match. """ metric_name = f"{function_name}-sender-auth-rejected" logs.MetricFilter( scope, f"{id_prefix}SenderAuthRejectedFilter", - log_group=logs.LogGroup.from_log_group_name( - scope, - f"{id_prefix}LogGroup", - f"/aws/lambda/{function_name}", - ), + log_group=log_group, filter_pattern=logs.FilterPattern.literal('"sender_auth_rejected"'), metric_namespace=_SENDER_AUTH_METRIC_NAMESPACE, metric_name=metric_name, diff --git a/cdk/po_stack.py b/cdk/po_stack.py index 1cfb51c..b03f572 100644 --- a/cdk/po_stack.py +++ b/cdk/po_stack.py @@ -11,7 +11,6 @@ from aws_cdk import ( aws_kms as kms, aws_lambda as lambda_, aws_lambda_event_sources as lambda_event_sources, - aws_logs as logs, aws_s3 as s3, aws_s3_notifications as s3n, aws_ses as ses, @@ -91,6 +90,9 @@ class PoIngestStack(Stack): email_processor_dlq = common.make_processor_dlq(self, "EmailProcessorDlq") # --- Lambda function --- + email_processor_log_group = common.make_function_log_group( + self, "EmailProcessor", "po-email-processor" + ) email_processor = lambda_.Function( self, "EmailProcessor", @@ -134,7 +136,7 @@ class PoIngestStack(Stack): ), timeout=Duration.seconds(60), memory_size=256, - log_retention=logs.RetentionDays.TWO_MONTHS, + log_group=email_processor_log_group, dead_letter_queue=email_processor_dlq, environment={ "PO_TABLE": "purchase-orders", @@ -196,7 +198,11 @@ class PoIngestStack(Stack): # false-reject storm pages instead of vanishing. default_value=0 keeps the # series populated (alarm stays OK, never INSUFFICIENT_DATA) between events. common.add_sender_auth_rejected_alarm( - self, "EmailProcessor", "po-email-processor", alarm_topic + self, + "EmailProcessor", + "po-email-processor", + alarm_topic, + email_processor_log_group, ) # --- Template fallback-rate alarm: po-email-processor --- @@ -372,6 +378,7 @@ class PoIngestStack(Stack): ) # --- Web UI Lambda --- + web_ui_log_group = common.make_function_log_group(self, "WebUI", "po-web-ui") web_ui = lambda_.Function( self, "WebUI", @@ -409,7 +416,7 @@ class PoIngestStack(Stack): ), timeout=Duration.seconds(60), memory_size=256, - log_retention=logs.RetentionDays.TWO_MONTHS, + log_group=web_ui_log_group, environment={ "PO_TABLE": "purchase-orders", # Defense-in-depth shared secret for the web UI handler. The @@ -464,6 +471,9 @@ class PoIngestStack(Stack): # 518 WCU of write amplification. Re-add if a state-level query path ships. # --- Site extractor Lambda (DynamoDB Streams → verified-sites) --- + site_extractor_log_group = common.make_function_log_group( + self, "SiteExtractor", "po-ingest-site-extractor" + ) site_extractor = lambda_.Function( self, "SiteExtractor", @@ -483,7 +493,7 @@ class PoIngestStack(Stack): ), timeout=Duration.seconds(60), memory_size=256, - log_retention=logs.RetentionDays.TWO_MONTHS, + log_group=site_extractor_log_group, environment={ "VERIFIED_SITES_TABLE": verified_sites_table.table_name, "PENDING_REVIEW_TABLE": "pending-site-review", diff --git a/cdk/wo_stack.py b/cdk/wo_stack.py index 28756a2..726cfa5 100644 --- a/cdk/wo_stack.py +++ b/cdk/wo_stack.py @@ -9,7 +9,6 @@ from aws_cdk import ( aws_cloudwatch_actions as cw_actions, aws_dynamodb as dynamodb, aws_lambda as lambda_, - aws_logs as logs, aws_s3 as s3, aws_s3_notifications as s3n, aws_ses as ses, @@ -89,6 +88,9 @@ class WorkorderIngestStack(Stack): email_processor_dlq = common.make_processor_dlq(self, "EmailProcessorDlq") # --- Lambda function --- + email_processor_log_group = common.make_function_log_group( + self, "EmailProcessor", "workorder-email-processor" + ) email_processor = lambda_.Function( self, "EmailProcessor", @@ -119,7 +121,7 @@ class WorkorderIngestStack(Stack): ), timeout=Duration.seconds(60), memory_size=256, - log_retention=logs.RetentionDays.TWO_MONTHS, + log_group=email_processor_log_group, dead_letter_queue=email_processor_dlq, environment={ "WORK_ORDERS_TABLE": work_orders_table.table_name, @@ -194,7 +196,11 @@ class WorkorderIngestStack(Stack): # dropped. This metric filter + alarm turns those warnings into a paging # signal so a false-reject storm surfaces instead of a silent outage. common.add_sender_auth_rejected_alarm( - self, "EmailProcessor", "workorder-email-processor", alarm_topic + self, + "EmailProcessor", + "workorder-email-processor", + alarm_topic, + email_processor_log_group, ) # --- Template fallback-rate alarm: workorder-email-processor --- @@ -304,6 +310,9 @@ class WorkorderIngestStack(Stack): ) # --- Web UI Lambda --- + web_ui_log_group = common.make_function_log_group( + self, "WebUI", "workorder-web-ui" + ) web_ui = lambda_.Function( self, "WebUI", @@ -340,7 +349,7 @@ class WorkorderIngestStack(Stack): ), timeout=Duration.seconds(15), memory_size=128, - log_retention=logs.RetentionDays.TWO_MONTHS, + log_group=web_ui_log_group, environment={ "WORK_ORDERS_TABLE": work_orders_table.table_name, "COMMENTS_TABLE": comments_table.table_name,