From 793b59da838519dca297aa7de9924cbcd080ae68 Mon Sep 17 00:00:00 2001 From: Ealynn Hsu Date: Tue, 14 Jul 2026 13:52:18 +0000 Subject: [PATCH 1/6] [WIP] Job notifications setup --- .../train/common_utils/notifications.py | 332 ++++++++++++++++++ .../train/common_utils/test_notifications.py | 211 +++++++++++ 2 files changed, 543 insertions(+) create mode 100644 sagemaker-train/src/sagemaker/train/common_utils/notifications.py create mode 100644 sagemaker-train/tests/unit/train/common_utils/test_notifications.py diff --git a/sagemaker-train/src/sagemaker/train/common_utils/notifications.py b/sagemaker-train/src/sagemaker/train/common_utils/notifications.py new file mode 100644 index 0000000000..234f8900da --- /dev/null +++ b/sagemaker-train/src/sagemaker/train/common_utils/notifications.py @@ -0,0 +1,332 @@ +"""Job notification utilities for SageMaker training jobs. + +Manages EventBridge rules that route SageMaker Training Job status change +events to user-provided SNS topics. Supports SMTJ (serverless and serverful) +training jobs only. +""" + +from __future__ import absolute_import + +import hashlib +import json +import logging +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Rule name prefix used to identify SDK-created rules +_RULE_NAME_PREFIX = "sm-pysdk-job-notif" + +# Default events to notify on +_DEFAULT_EVENTS = ["Completed", "Failed", "Stopped"] + +# Valid event status values +_VALID_EVENTS = {"Completed", "Failed", "Stopped", "InProgress"} + + +def _get_rule_name(sns_topic_arn: str) -> str: + """Generate a deterministic rule name from the SNS topic ARN. + + Args: + sns_topic_arn: The SNS topic ARN. + + Returns: + Rule name like "sm-pysdk-job-notif-a3f8b2c1". + """ + arn_hash = hashlib.sha256(sns_topic_arn.encode()).hexdigest()[:8] + return f"{_RULE_NAME_PREFIX}-{arn_hash}" + + +def _normalize_events(events: Optional[List[str]]) -> List[str]: + """Normalize and validate event status values. + + Args: + events: List of event names (e.g., ["completed", "failed"]). + If None, returns all default events. + + Returns: + List of capitalized event status strings. + + Raises: + ValueError: If an invalid event name is provided. + """ + if not events: + return _DEFAULT_EVENTS.copy() + + normalized = [] + for event in events: + capitalized = event.capitalize() + if capitalized == "Inprogress": + capitalized = "InProgress" + if capitalized not in _VALID_EVENTS: + raise ValueError( + f"Invalid notification event: '{event}'. " + f"Valid events: {sorted(_VALID_EVENTS)}" + ) + normalized.append(capitalized) + + return normalized + + +def _build_event_pattern( + events: List[str], + job_name_prefix: Optional[str] = None, +) -> str: + """Build the EventBridge event pattern JSON for SMTJ job status changes. + + Args: + events: List of TrainingJobStatus values to match. + job_name_prefix: Optional job name prefix filter. + + Returns: + JSON string of the event pattern. + """ + pattern: Dict = { + "source": ["aws.sagemaker"], + "detail-type": ["SageMaker Training Job State Change"], + "detail": { + "TrainingJobStatus": events, + }, + } + + if job_name_prefix: + pattern["detail"]["TrainingJobName"] = [{"prefix": job_name_prefix}] + + return json.dumps(pattern) + + +def _validate_notifications_permissions(events_client) -> None: + """Validate the caller has permissions to manage EventBridge rules. + + Args: + events_client: boto3 EventBridge client. + + Raises: + PermissionError: If the caller lacks required EventBridge permissions. + """ + try: + events_client.list_rules(NamePrefix=_RULE_NAME_PREFIX, Limit=1) + except events_client.exceptions.ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + if error_code in ("AccessDeniedException", "AccessDenied"): + raise PermissionError( + "Missing permissions to manage EventBridge rules. " + "Ensure your caller identity has: " + "events:PutRule, events:PutTargets, events:ListRules, " + "events:RemoveTargets, events:DeleteRule." + ) from e + raise + + +def _validate_sns_topic(sns_client, topic_arn: str) -> None: + """Validate that the SNS topic exists and is accessible. + + Args: + sns_client: boto3 SNS client. + topic_arn: The SNS topic ARN to validate. + + Raises: + ValueError: If the topic doesn't exist or isn't accessible. + """ + try: + sns_client.get_topic_attributes(TopicArn=topic_arn) + except Exception as e: + error_msg = str(e) + if "NotFound" in error_msg or "does not exist" in error_msg.lower(): + raise ValueError( + f"SNS topic not found: {topic_arn}. " + "Ensure the topic exists and you have sns:GetTopicAttributes permission." + ) from e + if "AuthorizationError" in error_msg or "AccessDenied" in error_msg: + raise PermissionError( + f"Cannot access SNS topic: {topic_arn}. " + "Ensure you have sns:GetTopicAttributes permission." + ) from e + raise + + +def enable_notifications( + sns_topic_arn: str, + sagemaker_session, + events: Optional[List[str]] = None, + event_bus_arn: Optional[str] = None, + job_name_prefix: Optional[str] = None, +) -> str: + """Create or update an EventBridge rule for training job notifications. + + Args: + sns_topic_arn: ARN of the SNS topic to receive notifications. + sagemaker_session: SageMaker session (provides boto_session). + events: List of job statuses to notify on. Defaults to + ["Completed", "Failed", "Stopped"]. + event_bus_arn: Optional EventBridge bus ARN. Defaults to the + account's default bus. + job_name_prefix: Optional job name prefix to filter notifications. + + Returns: + The ARN of the created/updated EventBridge rule. + + Raises: + ValueError: If sns_topic_arn is invalid or topic doesn't exist. + PermissionError: If caller lacks required permissions. + """ + if not sns_topic_arn or not sns_topic_arn.startswith("arn:aws:sns:"): + raise ValueError( + f"Invalid SNS topic ARN: '{sns_topic_arn}'. " + "Must be a valid ARN like 'arn:aws:sns:us-east-1:012345678910:my-topic'." + ) + + region_name = sagemaker_session.boto_session.region_name + events_client = sagemaker_session.boto_session.client("events", region_name=region_name) + sns_client = sagemaker_session.boto_session.client("sns", region_name=region_name) + + _validate_notifications_permissions(events_client) + _validate_sns_topic(sns_client, sns_topic_arn) + + normalized_events = _normalize_events(events) + rule_name = _get_rule_name(sns_topic_arn) + event_pattern = _build_event_pattern(normalized_events, job_name_prefix) + + put_rule_kwargs = { + "Name": rule_name, + "EventPattern": event_pattern, + "State": "ENABLED", + "Description": f"SageMaker PySDK training job notifications -> {sns_topic_arn}", + } + if event_bus_arn: + put_rule_kwargs["EventBusName"] = event_bus_arn + + response = events_client.put_rule(**put_rule_kwargs) + rule_arn = response["RuleArn"] + logger.info(f"EventBridge rule created/updated: {rule_name} ({rule_arn})") + + # Add SNS topic as target with formatted message + target_id = f"{rule_name}-sns-target" + # Use a JSON object template — SNS renders JSON objects with line breaks + input_template = ( + '{"Job": "",' + ' "Status": "",' + ' "Time": "