"""AWS Lambda handler for DynamoDB snapshot storage.""" import logging import os from datetime import datetime, timezone from typing import Any import sentry_sdk from config import DynamoConfig from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration from shared.schemas import ( DeleteSnapshotEvent, DeleteSnapshotResponse, GetSnapshotEvent, GetSnapshotResponse, PutSnapshotEvent, PutSnapshotResponse, Snapshot, ) from dynamo_client.dynamo_client import DynamoClient sentry_sdk.init( dsn=os.environ.get('SENTRY_DSN'), environment=os.environ.get('ENVIRONMENT'), integrations=[AwsLambdaIntegration()], ) LOGGER = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s: %(message)s' ) # Module-level singletons — initialised once per Lambda cold start _config: 'DynamoConfig | None' = None _client: 'DynamoClient | None' = None def _get_config() -> DynamoConfig: """Return the module-level DynamoConfig singleton, creating it if needed.""" global _config if _config is None: _config = DynamoConfig() return _config def _get_client() -> DynamoClient: """Return the module-level DynamoClient singleton, creating it if needed.""" global _client if _client is None: cfg = _get_config() _client = DynamoClient( table_name=cfg.dynamodb_table_name, region=cfg.aws_region, ) return _client def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: """Route Lambda event to the appropriate action handler. :param event: Lambda event dict with required 'action' key. :param context: Lambda context object (unused). :return: Action-specific response dict. :raises ValueError: If action is unknown. """ action = event.get('action') if action == 'put-snapshot': return _handle_put_snapshot(PutSnapshotEvent.model_validate(event)) if action == 'get-snapshot': return _handle_get_snapshot(GetSnapshotEvent.model_validate(event)) if action == 'delete-snapshot': return _handle_delete_snapshot(DeleteSnapshotEvent.model_validate(event)) raise ValueError(f'Unknown action: {action!r}') def _handle_put_snapshot(evt: PutSnapshotEvent) -> dict[str, Any]: """Store a Phase 1 discovery snapshot in DynamoDB. Overwrites any existing record for the same ticket. TTL is set to ``dynamodb_ttl_days`` days from now so stale records auto-expire. :param evt: Validated event with ticket data and discovery results. :return: {"ticket_id", "stored", "dry_run"} """ if evt.dry_run: LOGGER.info('[Dry run] Would store snapshot for ticket %s.', evt.ticket_id) return PutSnapshotResponse( ticket_id=evt.ticket_id, stored=False, dry_run=True ).model_dump() snapshot = Snapshot( ticket_id=evt.ticket_id, email=evt.email, full_name=evt.full_name, last_working_day=evt.last_working_day, auth0_matches=evt.auth0_matches, terraform_hits=evt.terraform_hits, auth0_action=evt.auth0_action, checked_at=datetime.now(tz=timezone.utc).isoformat(), ) cfg = _get_config() _get_client().put_snapshot(snapshot, ttl_days=cfg.dynamodb_ttl_days) LOGGER.info('Stored snapshot for ticket %s.', evt.ticket_id) return PutSnapshotResponse( ticket_id=evt.ticket_id, stored=True, dry_run=False ).model_dump() def _handle_get_snapshot(evt: GetSnapshotEvent) -> dict[str, Any]: """Retrieve a stored Phase 1 snapshot from DynamoDB. Returns ``found=False`` with ``snapshot=None`` when no record exists — callers (Step Functions) should treat this as a safety abort signal. :param evt: Validated event with ticket_id. :return: {"ticket_id", "found", "snapshot"} """ snapshot = _get_client().get_snapshot(evt.ticket_id) if snapshot is None: LOGGER.warning('No snapshot found for ticket %s.', evt.ticket_id) return GetSnapshotResponse(ticket_id=evt.ticket_id, found=False).model_dump() LOGGER.info('Retrieved snapshot for ticket %s.', evt.ticket_id) return GetSnapshotResponse( ticket_id=evt.ticket_id, found=True, snapshot=snapshot ).model_dump() def _handle_delete_snapshot(evt: DeleteSnapshotEvent) -> dict[str, Any]: """Delete a snapshot after Phase 2 has completed. Idempotent — safe to call even if the record no longer exists. :param evt: Validated event with ticket_id and dry_run flag. :return: {"ticket_id", "deleted", "dry_run"} """ if evt.dry_run: LOGGER.info('[Dry run] Would delete snapshot for ticket %s.', evt.ticket_id) return DeleteSnapshotResponse( ticket_id=evt.ticket_id, deleted=False, dry_run=True ).model_dump() _get_client().delete_snapshot(evt.ticket_id) LOGGER.info('Deleted snapshot for ticket %s.', evt.ticket_id) return DeleteSnapshotResponse( ticket_id=evt.ticket_id, deleted=True, dry_run=False ).model_dump()