|
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
|