Project

General

Profile

Task #144 » template.yaml

YAML file: AWSTemplateFormatVersion: "2010-09-09" - Akshath Shettigara, 10/15/2026 04:35 PM

 
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
NSDL SEZ BOE/DTA Automation -- Phase A (Fetch), Phase B (Deliver),
Secrets Manager, and the /sez/deliver-pending API. Monitoring/alerting
resources are added in a later build-spec phase.

Parameters:
Environment:
Type: String
Default: TEST
AllowedValues: [TEST, PROD]
Description: Deployment environment, passed via --parameter-overrides (Build Spec Phase 10).

# --- Explicitly NOT defaulted -- both are named in the build spec as
# values that must come from a human, never invented by an agent. This
# forces `sam deploy --guided` to prompt for them (or fail without
# --parameter-overrides) instead of silently deploying a guessed value.
S3RetentionDays:
Type: Number
Description: >
REQUIRED, no default. Total days before a raw NSDL export is expired
(deleted) from S3, per Blueprint SS7/SS13 item 1. Pending confirmation
of any Indian customs/SEZ minimum recordkeeping requirement -- do not
guess this number.
MinValue: 1

DailyScheduleCronExpression:
Type: String
Description: >
REQUIRED, no default. EventBridge schedule expression for Phase A's
daily run, e.g. "cron(0 9 * * ? *)" (must be confirmed with the
business -- what UTC time should this actually run at?).
AllowedPattern: "^(cron|rate)\\(.+\\)$"
ConstraintDescription: "Must be a valid EventBridge cron(...) or rate(...) expression."

Conditions:
# The scheduled Phase B runs below name specific clients (eap, wud). Those are the TEST
# stack's clients; PROD's clients and times are a separate decision, so PROD gets no
# scheduled Phase B runs until its own rules are added.
IsTestStack: !Equals [!Ref Environment, TEST]

Resources:
# ---------------------------------------------------------------------
# Secrets Manager (Blueprint SS9 / Build Spec Phase 5)
# ---------------------------------------------------------------------
# This resource only creates an initial PLACEHOLDER value on first
# deploy -- real NSDL/Frappe credentials are never generated or guessed
# here. A human fills them in afterward via `aws secretsmanager
# put-secret-value` or the console (see README.md for the expected JSON
# shape). CloudFormation only regenerates GenerateSecretString when this
# block itself changes, so a manually-set real value is NOT overwritten
# by a later `sam deploy` of an otherwise-unchanged template.
#
# Name is suffixed with ${Environment} (a deviation from the build
# spec's literal unsuffixed "nsdl-boe-automation-secrets") because
# Secrets Manager names must be unique per account+region, and Phase 10
# deploys both a TEST and a PROD stack from this same template -- an
# unsuffixed name would collide on the second deploy.
NsdlBoeSecrets:
Type: AWS::SecretsManager::Secret
Properties:
Name: !Sub "nsdl-boe-automation-secrets-${Environment}"
Description: >
NSDL login credentials + per-client Frappe credentials. Placeholder
value only -- fill in real values via put-secret-value. Expected
shape documented in README.md.
GenerateSecretString:
SecretStringTemplate: '{"NSDL_ACCOUNTS": {}, "CLIENTS": {}}'
GenerateStringKey: "placeholder"
ExcludePunctuation: true
Tags:
- Key: Environment
Value: !Ref Environment

# ---------------------------------------------------------------------
# S3 -- raw NSDL export storage (Blueprint SS7)
# ---------------------------------------------------------------------
RawFilesBucket:
Type: AWS::S3::Bucket
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: TransitionAndExpire
Status: Enabled
Transitions:
- TransitionInDays: 30
StorageClass: STANDARD_IA
ExpirationInDays: !Ref S3RetentionDays
Tags:
- Key: Environment
Value: !Ref Environment

# ---------------------------------------------------------------------
# DynamoDB (Blueprint SS8 / Build Spec Phase 4)
# ---------------------------------------------------------------------
StagedRecordsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- {AttributeName: request_id, AttributeType: S}
- {AttributeName: client_name, AttributeType: S}
KeySchema:
- {AttributeName: request_id, KeyType: HASH}
GlobalSecondaryIndexes:
- IndexName: client_name-status-index
KeySchema:
- {AttributeName: client_name, KeyType: HASH}
Projection: {ProjectionType: ALL}
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
Tags:
- Key: Environment
Value: !Ref Environment

RunGateTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- {AttributeName: run_date, AttributeType: S}
- {AttributeName: client_name, AttributeType: S}
KeySchema:
- {AttributeName: run_date, KeyType: HASH}
- {AttributeName: client_name, KeyType: RANGE}
Tags:
- Key: Environment
Value: !Ref Environment

RunControlTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- {AttributeName: run_date, AttributeType: S}
KeySchema:
- {AttributeName: run_date, KeyType: HASH}
Tags:
- Key: Environment
Value: !Ref Environment

# ---------------------------------------------------------------------
# Phase A Lambda (Build Spec Phase 3, Secrets wiring added in Phase 5)
# ---------------------------------------------------------------------
PhaseAFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Timeout: 900
MemorySize: 3008
Environment:
Variables:
RAW_FILES_BUCKET: !Ref RawFilesBucket
RUN_CONTROL_TABLE: !Ref RunControlTable
STAGED_RECORDS_TABLE: !Ref StagedRecordsTable
RUN_GATE_TABLE: !Ref RunGateTable
# !Ref on a Secret resource yields its ARN, not its short name --
# despite this env var's name, that's intentional: boto3's
# get_secret_value(SecretId=...) accepts either form, and using
# the ARN here avoids duplicating the literal secret name in a
# second place in this template.
NSDL_BOE_SECRET_NAME: !Ref NsdlBoeSecrets
# Non-secret operational constants as env vars, per the project's
# config strategy decision -- values are fw_verify's own proven
# defaults (config.sample.yaml), not invented here.
NSDL_BASE_URL: "https://www.sezonline-ndml.co.in/User/Login.aspx"
NSDL_MODULE_BE: "17"
NSDL_MODULE_DTA: "26"
NSDL_ACCOUNT_KEY: "siddhartha_ftwz"
MAX_LOGIN_ATTEMPTS: "8"
# Email-OTP 2FA at NSDL login (see shared/nsdl_login.py). Non-secret
# tuning only -- the Frappe URL/API key/secret and Email Account
# name live in the Secrets Manager secret (OTP_* keys), never here.
# Defaults mirror the proven reference implementation; NSDL locks
# the account for 30 min after 5 wrong OTPs, so keep submit
# attempts low.
NSDL_OTP_ENABLED: "true"
# The OTP mail path (NSDL -> manager mailbox -> forward -> OTP
# mailbox -> Frappe) can take several minutes, and every resend
# invalidates the previous OTP. So the login waits up to
# NSDL_OTP_WAIT_SECONDS (capped by the OTP's 10-minute validity) for a
# mail sent after the Login click, and only resends after NSDL has
# REJECTED an OTP (NSDL_OTP_RESEND_ON_TIMEOUT=false). Mails older than
# the request are ignored, never submitted.
NSDL_OTP_WAIT_SECONDS: "540"
NSDL_OTP_RESEND_ON_TIMEOUT: "false"
# The OTP Frappe site answers Communication queries in ~20s (very large table), so allow
# well over that per request; a timeout while waiting for the mail is retried, not fatal.
NSDL_OTP_REQUEST_TIMEOUT_SECONDS: "90"
NSDL_OTP_MAX_SUBMIT_ATTEMPTS: "2"
NSDL_OTP_MAX_RESENDS: "1"
# NSDL CAPTCHA is 5 characters; a reading is only submitted when enough
# OCR variants agree, otherwise a fresh CAPTCHA is requested.
NSDL_CAPTCHA_EXPECTED_LENGTH: "5"
STEP2_QC_STRICT: "true"
FAIL_ON_ZERO_ROWS: "false"
# NSDL sends no file when a module (BOE or DTA) has no requests in the
# date window. A timeout with no error shown on the portal is therefore
# treated as 'no data' for that module, and the other module is still
# exported. Set to "false" to fail the run instead.
TREAT_EXPORT_TIMEOUT_AS_NO_DATA: "true"
# Lambda's real execution environment runs this container as a
# non-root sandbox user (e.g. sbx_user1051) whose actual $HOME
# (/home/sbx_user1051) is on the read-only part of the filesystem
# -- only /tmp is writable. Several libraries in the Playwright/
# EasyOCR/torch stack assume a writable $HOME for their own
# caches regardless of any per-call override passed in code, so
# this is set globally rather than chased library-by-library.
# Not reproduced under `sam local invoke` (a normal Docker
# container, not Lambda's sandboxed runtime).
HOME: "/tmp"
# No async retries: a retried invocation re-runs the OTP login, and
# repeated failed logins can trip NSDL's 30-minute account lockout
# (which would also block Phase B). The next scheduled run is the retry.
EventInvokeConfig:
MaximumRetryAttempts: 0
Events:
DailySchedule:
Type: Schedule
Properties:
Schedule: !Ref DailyScheduleCronExpression
# No run_date here -- EventBridge schedule targets can only
# carry a static Input, so lambda_handler.py defaults run_date
# to today (UTC) whenever the event omits it.
Input: '{"force_refresh": false}'
Policies:
- S3CrudPolicy: {BucketName: !Ref RawFilesBucket}
- DynamoDBCrudPolicy: {TableName: !Ref StagedRecordsTable}
- DynamoDBCrudPolicy: {TableName: !Ref RunGateTable}
- DynamoDBCrudPolicy: {TableName: !Ref RunControlTable}
- AWSSecretsManagerGetSecretValuePolicy: {SecretArn: !Ref NsdlBoeSecrets}
Tags:
Environment: !Ref Environment
Metadata:
# Repo root, not app/phase_a_fetch -- the Dockerfile needs to reach
# shared/ as well as this function's own code (see Phase 2 note on
# shared/ packaging).
DockerContext: .
Dockerfile: app/phase_a_fetch/Dockerfile

# ---------------------------------------------------------------------
# Phase B Lambda (Build Spec Phase 6/7)
# ---------------------------------------------------------------------
# PackageType/Timeout/MemorySize deliberately DIVERGE from Build Spec
# Phase 7's literal snippet (which shows no PackageType and a 300s
# timeout). Per the user's explicit decision (Phase 6), this function
# ports fw_verify's real Playwright/NSDL-login PDF-fetch flow, not a
# lightweight HTTP-only fetch -- it needs the same heavy container image
# and realistic timeout as Phase A.
PhaseBFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Timeout: 900
MemorySize: 3008
Environment:
Variables:
STAGED_RECORDS_TABLE: !Ref StagedRecordsTable
NSDL_BOE_SECRET_NAME: !Ref NsdlBoeSecrets
NSDL_BASE_URL: "https://www.sezonline-ndml.co.in/User/Login.aspx"
NSDL_ACCOUNT_KEY: "siddhartha_ftwz"
MAX_LOGIN_ATTEMPTS: "8"
# Email-OTP 2FA at NSDL login (see shared/nsdl_login.py). Non-secret
# tuning only -- the Frappe URL/API key/secret and Email Account
# name live in the Secrets Manager secret (OTP_* keys), never here.
# Defaults mirror the proven reference implementation; NSDL locks
# the account for 30 min after 5 wrong OTPs, so keep submit
# attempts low.
NSDL_OTP_ENABLED: "true"
# The OTP mail path (NSDL -> manager mailbox -> forward -> OTP
# mailbox -> Frappe) can take several minutes, and every resend
# invalidates the previous OTP. So the login waits up to
# NSDL_OTP_WAIT_SECONDS (capped by the OTP's 10-minute validity) for a
# mail sent after the Login click, and only resends after NSDL has
# REJECTED an OTP (NSDL_OTP_RESEND_ON_TIMEOUT=false). Mails older than
# the request are ignored, never submitted.
NSDL_OTP_WAIT_SECONDS: "540"
NSDL_OTP_RESEND_ON_TIMEOUT: "false"
# The OTP Frappe site answers Communication queries in ~20s (very large table), so allow
# well over that per request; a timeout while waiting for the mail is retried, not fatal.
NSDL_OTP_REQUEST_TIMEOUT_SECONDS: "90"
NSDL_OTP_MAX_SUBMIT_ATTEMPTS: "2"
NSDL_OTP_MAX_RESENDS: "1"
# NSDL CAPTCHA is 5 characters; a reading is only submitted when enough
# OCR variants agree, otherwise a fresh CAPTCHA is requested.
NSDL_CAPTCHA_EXPECTED_LENGTH: "5"
STAGED_TTL_DAYS: "60"
PDF_PAUSE_EVERY: "15"
PDF_PAUSE_SECONDS: "120"
# Caps how many pending PDFs one invocation attempts, so a large
# backlog can't run this invocation into Lambda's 900s hard
# timeout (reproduced for real at 64 records/31 PDFs) -- anything
# beyond this stays PUSHED and is picked up by the next
# invocation. 30 is a conservative estimate (~9s/PDF plus
# periodic pause cooldowns, leaving headroom for a cold-start's
# OCR model download + NSDL login) pending real multi-day volume
# data to tune further.
MAX_PDF_ATTACHES_PER_INVOCATION: "30"
# See PhaseAFunction's matching HOME override for why this is
# required on the real deployed Lambda.
HOME: "/tmp"
# EventInvokeConfig only matters now that this function is invoked
# ASYNCHRONOUSLY (see the API Gateway wiring above) -- for a
# synchronous call it would be silently ignored. MaximumRetryAttempts
# matches the existing courier/shipping services' convention. A
# retry (or the original invoke) landing twice is safe: Phase B's
# own logic is idempotent (checks Frappe before creating, checks
# is_pdf_already_attached before attaching).
EventInvokeConfig:
MaximumRetryAttempts: 2
DestinationConfig:
OnFailure:
Type: SQS
Destination: !GetAtt PhaseBDeadLetterQueue.Arn
Policies:
- DynamoDBCrudPolicy: {TableName: !Ref StagedRecordsTable}
- AWSSecretsManagerGetSecretValuePolicy: {SecretArn: !Ref NsdlBoeSecrets}
- SQSSendMessagePolicy: {QueueName: !GetAtt PhaseBDeadLetterQueue.QueueName}
# NOTE: no RunGateTable policy here, unlike Build Spec Phase 7's
# literal snippet -- Phase B's actual code (app/phase_b_deliver/)
# never touches RunGateTable (see Phase 6 write-up: that table is
# Phase A's own QC-gate view; Phase B's delivery outcome is
# recorded via the NSDL Sync Run Frappe doc instead). Granting an
# unused table permission would violate least-privilege for no
# benefit.
Tags:
Environment: !Ref Environment
Metadata:
DockerContext: .
Dockerfile: app/phase_b_deliver/Dockerfile

# ---------------------------------------------------------------------
# Scheduled Phase B runs, started from AWS (no Frappe scheduler involved)
# ---------------------------------------------------------------------
# Phase A stages once a day (06:30 IST). Phase B only has new work after that; the later runs
# exist to work through a backlog (30 PDFs per run) and to retry a failed login. All clients
# share ONE NSDL account, so runs must never overlap (two simultaneous logins can pick up each
# other's OTP mail): each client gets its own slot, 30 minutes apart, and a run is capped at
# 15 minutes. Runs started by hand near a slot, or by Frappe's own scheduled job, are NOT
# covered by this spacing -- disable the Frappe-side trigger when relying on these.
# EventBridge cron is UTC: 01:30/05:30/09:30 = 07:00/11:00/15:00 IST, 02:00/06:00/10:00 = 07:30/11:30/15:30 IST.
# A new client needs its own pair of resources here (and, if it is a PROD client, a PROD rule).
PhaseBScheduleEap:
Type: AWS::Events::Rule
Condition: IsTestStack
Properties:
Description: "Phase B delivery for client eap at 07:00, 11:00 and 15:00 IST daily"
ScheduleExpression: "cron(30 1,5,9 * * ? *)"
State: ENABLED
Targets:
- Id: PhaseBEap
Arn: !GetAtt PhaseBFunction.Arn
Input: '{"client_name": "eap"}'

PhaseBSchedulePermissionEap:
Type: AWS::Lambda::Permission
Condition: IsTestStack
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref PhaseBFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt PhaseBScheduleEap.Arn

PhaseBScheduleWud:
Type: AWS::Events::Rule
Condition: IsTestStack
Properties:
Description: "Phase B delivery for client wud at 07:30, 11:30 and 15:30 IST daily"
ScheduleExpression: "cron(0 2,6,10 * * ? *)"
State: ENABLED
Targets:
- Id: PhaseBWud
Arn: !GetAtt PhaseBFunction.Arn
Input: '{"client_name": "wud"}'

PhaseBSchedulePermissionWud:
Type: AWS::Lambda::Permission
Condition: IsTestStack
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref PhaseBFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt PhaseBScheduleWud.Arn

# API Gateway is defined explicitly (not via the function's own Events:
# Api shorthand) because /sez/deliver-pending needs a custom, non-proxy
# request mapping template to turn the X-Frappe-Client header into the
# Lambda event's client_name field -- SAM's shorthand only supports
# proxy integrations.
SezApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Ref Environment
Auth:
ApiKeyRequired: true
DefinitionBody:
swagger: "2.0"
info:
title: !Sub "nsdl-sez-boe-automation-${Environment}"
paths:
/sez/deliver-pending:
post:
parameters:
- name: X-Frappe-Client
in: header
required: true
type: string
x-amazon-apigateway-integration:
type: aws
httpMethod: POST
uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PhaseBFunction.Arn}/invocations"
passthroughBehavior: never
# Async invocation (matches the existing courier/shipping
# services' pattern in the production repo) -- REQUIRED,
# not optional: API Gateway REST APIs cap a synchronous
# integration at 29s, and Phase B's real NSDL Playwright
# login + throttled PDF downloads routinely runs far
# longer than that. This is also why Blueprint SS11 calls
# for a DLQ on "Phase B async invocation" failures -- it
# was always meant to work this way.
requestParameters:
integration.request.header.X-Amz-Invocation-Type: "'Event'"
requestTemplates:
# sync_run_name is read from the JSON request body (the
# Frappe-side scheduled job's own NSDL Sync Run docname,
# created as the parent record before this call) so Phase
# B updates that same doc instead of creating a new one.
# $input.json(...) (not $input.path(...)) is used because
# it degrades to the bare token `null` if the field or
# body is absent, rather than throwing -- it already
# returns a quoted JSON string, so no extra quotes here.
application/json: |
{
"client_name": "$input.params('X-Frappe-Client')",
"sync_run_name": $input.json('$.sync_run_name')
}
responses:
default:
statusCode: "202"
# An Event-invoked Lambda's real return value is never
# delivered back through API Gateway -- this fixed body
# just confirms Phase B accepted the trigger. Whether
# it actually SUCCEEDS is reported later via Phase B's
# own update_audit_doc() call back to the NSDL Sync Run
# doc, or via the DLQ/CloudWatch alarm on invoke failure.
responseTemplates:
application/json: '{"accepted": true}'
responses:
"202":
description: Accepted (processing continues asynchronously)

PhaseBApiInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref PhaseBFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${SezApi}/*/POST/sez/deliver-pending"

# ApiKey + UsagePlan -- SAM's Auth.ApiKeyRequired alone only turns on the
# REQUIREMENT for a key; without a UsagePlan associating a real key to
# this stage, every call would still be rejected. The key's VALUE is
# never exposed in CloudFormation -- retrieve it post-deploy with:
# aws apigateway get-api-key --api-key <SezApiKey physical ID> --include-value
SezApiKey:
Type: AWS::ApiGateway::ApiKey
Properties:
Enabled: true
Name: !Sub "sez-deliver-pending-key-${Environment}"

SezApiUsagePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
UsagePlanName: !Sub "sez-deliver-pending-usage-plan-${Environment}"
ApiStages:
- ApiId: !Ref SezApi
# !Ref SezApiStage (not !Ref Environment) is required here: SAM's
# implicit Stage resource for this API is named "SezApiStage", and
# referencing it (rather than the raw Environment parameter value)
# gives CloudFormation the dependency edge it needs to create the
# Stage before this UsagePlan -- without it, both start in
# parallel and the UsagePlan 404s looking for a stage that doesn't
# exist yet.
Stage: !Ref SezApiStage

SezApiUsagePlanKey:
Type: AWS::ApiGateway::UsagePlanKey
Properties:
KeyId: !Ref SezApiKey
KeyType: API_KEY
UsagePlanId: !Ref SezApiUsagePlan

# ---------------------------------------------------------------------
# Monitoring & alerting (Build Spec Phase 8 / Blueprint SS11)
# ---------------------------------------------------------------------
# ⚠️ MANUAL — HUMAN REQUIRED after deploy: subscribe real email/Slack
# endpoints to AlertsTopic. This template only creates the topic; no one
# receives anything until a human subscribes to it (console, or
# `aws sns subscribe`).
AlertsTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub "nsdl-boe-alerts-${Environment}"

# Only meaningful now that PhaseBFunction is invoked asynchronously (see
# that function's EventInvokeConfig above) -- a failure to even start
# the invocation (e.g. a cold-start crash, so no DynamoDB write and no
# application log at all) lands a record here instead of vanishing.
PhaseBDeadLetterQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "nsdl-boe-phase-b-dlq-${Environment}"
MessageRetentionPeriod: 1209600 # 14 days (SQS max) -- give a human time to notice and inspect

# A DLQ on its own just accumulates messages silently -- nobody is
# notified unless something is watching its depth. Not in Build Spec
# Phase 8's literal snippet, but added here since the DLQ is otherwise
# incomplete for the purpose Blueprint SS11 describes it for.
PhaseBDlqAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmDescription: "Phase B failed to even start (async invoke failure) -- see PhaseBDeadLetterQueue."
Namespace: AWS/SQS
MetricName: ApproximateNumberOfMessagesVisible
Dimensions:
- {Name: QueueName, Value: !GetAtt PhaseBDeadLetterQueue.QueueName}
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
AlarmActions: [!Ref AlertsTopic]

PhaseAErrorAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmDescription: "Phase A (the shared daily NSDL fetch) failed -- blocks every client until fixed."
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- {Name: FunctionName, Value: !Ref PhaseAFunction}
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
AlarmActions: [!Ref AlertsTopic]

# Hourly scan for StagedRecordsTable rows stuck in STAGED > 24h --
# catches both a silently-dead per-client Frappe scheduler and a
# chronically-failing push/attach (Blueprint SS3/SS11). Plain zip
# Lambda, no Docker/Playwright needed.
WatchdogFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub "nsdl-boe-watchdog-${Environment}"
CodeUri: app/watchdog/
Handler: lambda_handler.lambda_handler
Runtime: python3.12
Timeout: 60
MemorySize: 256
Environment:
Variables:
STAGED_RECORDS_TABLE: !Ref StagedRecordsTable
ALERTS_TOPIC_ARN: !Ref AlertsTopic
Events:
HourlySchedule:
Type: Schedule
Properties:
Schedule: "rate(1 hour)"
Policies:
- DynamoDBReadPolicy: {TableName: !Ref StagedRecordsTable}
- SNSPublishMessagePolicy: {TopicName: !GetAtt AlertsTopic.TopicName}
Tags:
Environment: !Ref Environment

Outputs:
NsdlBoeSecretsName:
Value: !Sub "nsdl-boe-automation-secrets-${Environment}"
RawFilesBucketName:
Value: !Ref RawFilesBucket
StagedRecordsTableName:
Value: !Ref StagedRecordsTable
RunGateTableName:
Value: !Ref RunGateTable
RunControlTableName:
Value: !Ref RunControlTable
PhaseAFunctionName:
Value: !Ref PhaseAFunction
PhaseBFunctionName:
Value: !Ref PhaseBFunction
SezApiDeliverPendingUrl:
Value: !Sub "https://${SezApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/sez/deliver-pending"
SezApiKeyId:
Description: "Logical API key ID -- fetch its value with: aws apigateway get-api-key --api-key <this value> --include-value"
Value: !Ref SezApiKey
AlertsTopicArn:
Description: "Subscribe real email/Slack endpoints here after deploy -- nothing is subscribed by default."
Value: !Ref AlertsTopic
PhaseBDeadLetterQueueUrl:
Value: !Ref PhaseBDeadLetterQueue
WatchdogFunctionName:
Value: !Ref WatchdogFunction
(3-3/3)