Files
civ6-play-by-cloud-turn-not…/serverless.yaml
2024-05-28 15:21:26 +09:30

522 lines
18 KiB
YAML

service: civ6-pbc${env:SERVERLESS_SERVICE_SUFFIX, ''}
useDotenv: true
provider:
name: aws
stackName: ${self:service}-${self:provider.stage}
runtime: nodejs16.x
memorySize: 128
timeout: 20
logRetentionInDays: ${self:custom.stageConfig.logRetentionInDays}
stage: ${opt:stage, env:NODE_ENV, 'development'}
profile: ${self:custom.stageConfig.profile}
region: us-west-2
logs:
restApi:
format: '{ "requestId":"$context.requestId", "ip": "$context.identity.sourceIp", "caller":"$context.identity.caller", "user":"$context.identity.user", "requestTime":"$context.requestTime", "httpMethod":"$context.httpMethod", "resourcePath":"$context.resourcePath", "status":"$context.status", "protocol":"$context.protocol", "responseLength":"$context.responseLength" }'
level: INFO # TODO: add custom field for setting this; default to ERROR for prod
environment:
GAME_TABLE: !Ref GameTable
MAIN_TABLE: !Ref MainTable
# Enable connection reuse for AWS SDK for instant performance boost
# https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/node-reusing-connections.html
AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
cors: true
package:
individually: true
plugins:
- serverless-domain-manager
- serverless-prune-plugin
- serverless-plugin-tracing
- serverless-iam-roles-per-function
- serverless-webpack
- serverless-apigateway-service-proxy
- serverless-stack-output
- serverless-cloudside-plugin
- serverless-plugin-metric
- '@brettstack/serverless-amplify-plugin'
- serverless-offline
- serverless-stack-termination-protection
custom:
stages:
development:
profile: civ6-pbc_dev
logRetentionInDays: 1
amplify:
api:
domainEnabled: false
alarms:
notificationEmail: ${env:ALARMS_NOTIFICATION_EMAIL, ''}
staging:
profile: civ6-pbc_staging
logRetentionInDays: 3
isDomainRoute53: false
api:
domainEnabled: true
domainName: staging.api.civ.halfstack.software
# validationDomain: halfstack.software
amplify:
domainName: staging.civ.halfstack.software
alarms:
notificationEmail: alerts@halfstack.software
production:
profile: civ6-pbc_prod
logRetentionInDays: 14
# retainDataResources: true
pointInTimeRecoveryEnabled: true
isDomainRoute53: false
api:
domainEnabled: true
domainName: api.civ.halfstack.software
# validationDomain: halfstack.software
amplify:
domainName: civ.halfstack.software
alarms:
notificationEmail: alerts@halfstack.software
stageConfig: ${self:custom.stages.${self:provider.stage}}
prune:
automatic: true
number: 10
customDomain:
domainName: ${self:custom.stageConfig.api.domainName, ''}
certificateName: ${self:custom.stageConfig.api.domainName, ''}
# enabled: false
enabled: ${self:custom.stageConfig.api.domainEnabled, false}
autoDomain: true #${self:custom.stageConfig.api.domainEnabled, false}
createRoute53Record: ${self:custom.stageConfig.api.isDomainRoute53, false}
serverless-offline:
httpPort: 4911
noPrependStageInUrl: true
amplify:
isManual: true
domainName: ${self:custom.stageConfig.amplify.domainName, ''}
buildSpecValues:
artifactBaseDirectory: packages/ui/build
preBuildWorkingDirectory: packages/ui
buildCommandEnvVars:
prefix: 'REACT_APP_'
allow:
- ApiEndpoint
webpack:
webpackConfig: ./functions.webpack.config.js
excludeFiles: packages/**/*.test.[t|j]s
output:
file: ./stack-outputs.${self:provider.stage}.json
metrics:
- name: SKIP_INACTIVE
pattern: '{ $.message = "PROCESS_MESSAGE:SKIP_INACTIVE" }'
functions: [webhook]
namespace: ${self:service}
- name: GAME_MARKED_INACTIVE
pattern: '{ $.message = "GAME_CONTROLLER:GAME_MARKED_INACTIVE" }'
functions: [webhook]
namespace: ${self:service}
- name: NOTIFICATION_SENT
pattern: '{ $.message = "PROCESS_MESSAGE:NOTIFICATION_SENT" }'
functions: [webhook]
namespace: ${self:service}
- name: GAME_CREATED
pattern: '{ $.message = "GAME_CONTROLLER:GAME_CREATED" }'
functions: [express]
namespace: ${self:service}
serverlessTerminationProtection:
stages:
- staging
- production
apiGatewayServiceProxies:
- sqs:
path: /webhook
method: post
queueName: !GetAtt WebhookSqsQueue.QueueName
requestParameters:
integration.request.querystring.MessageAttribute.1.Name: "'gameId'"
integration.request.querystring.MessageAttribute.1.Value.StringValue: method.request.querystring.gameId
integration.request.querystring.MessageAttribute.1.Value.DataType: "'String'"
functions:
webhook:
# NOTE: Lambda generally uses 5 pollers at low load and it's recommended to set reservedConcurrency to at least 5
# https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html#events-sqs-scaling
# https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html#events-sqs-eventsource
reservedConcurrency: 5
memorySize: 512
handler: packages/api/functions/webhook/lambda.webhookHandlerMiddy
layers:
- !Sub 'arn:aws:lambda:${aws:region}:580247275435:layer:LambdaInsightsExtension:12'
events:
- sqs:
arn: !GetAtt WebhookSqsQueue.Arn
batchSize: 100
maximumBatchingWindow: 20
iamRoleStatements:
- Effect: Allow
Action:
- sqs:SendMessage
Resource: !GetAtt WebhookSqsQueueDlq.Arn
- Effect: 'Allow'
Action:
- dynamodb:GetItem
- dynamodb:UpdateItem
Resource:
- !GetAtt GameTable.Arn
express:
handler: packages/api/functions/express/lambda.handler
memorySize: 1024
layers:
- !Sub 'arn:aws:lambda:${aws:region}:580247275435:layer:LambdaInsightsExtension:12'
events:
- http:
method: ANY
path: /
- http:
method: ANY
path: '{proxy+}'
iamRoleStatements:
- Effect: 'Allow'
Action:
- 'xray:PutTraceSegments'
- 'xray:PutTelemetryRecords'
Resource:
- '*'
- Effect: 'Allow'
Action:
- dynamodb:BatchGetItem
- dynamodb:BatchWriteItem
- dynamodb:DeleteItem
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:Query
- dynamodb:Scan
- dynamodb:UpdateItem
Resource:
- !GetAtt GameTable.Arn
resources:
Conditions:
IsApiCustomDomainEnabled: !Equals
- ${self:custom.stageConfig.api.domainEnabled, false}
- true
# RetainDataResources:
# !Equals
# - ${self:custom.stageConfig.retainDataResources, false}
# - true
Resources:
WebhookSqsQueue:
Type: AWS::SQS::Queue
Properties:
# NOTE: Following guidance here to reduce the chance of Lambda throttling
# https://medium.com/@zaccharles/lambda-concurrency-limits-and-sqs-triggers-dont-mix-well-sometimes-eb23d90122e0
# https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html#events-sqs-queueconfig
# https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html#events-sqs-eventsource
# functions.webhook.timeout * 6 + functions.webhook.events.sqs.maximumBatchingWindow
# 20 * 6 + 20
VisibilityTimeout: 140
RedrivePolicy:
deadLetterTargetArn: !GetAtt WebhookSqsQueueDlq.Arn
maxReceiveCount: 5
WebhookSqsQueueDlq:
Type: AWS::SQS::Queue
ApiGatewayMethodWebhookPost:
Type: AWS::ApiGateway::Method
Properties:
RequestParameters:
method.request.querystring.gameId: true
Integration:
IntegrationResponses:
- StatusCode: 200
ResponseTemplates:
application/json: '{}'
# NOTE: This only works when deploying to us-east-1 since ACM certs for EDGE APIs
# must exist in us-east-1 due to being associated with a CloudFront Distribution.
# Manually create cert in us-east-1 instead.
# AcmCertificate:
# Type: AWS::CertificateManager::Certificate
# Condition: IsApiCustomDomainEnabled
# Properties:
# DomainName: ${self:custom.stageConfig.api.domainName}
# DomainValidationOptions:
# - DomainName: ${self:custom.stageConfig.api.domainName}
# ValidationDomain: ${self:custom.stageConfig.api.validationDomain, ''}
MainTable:
Type: AWS::DynamoDB::Table
# DeletionPolicy: !If [RetainDataResources, 'Retain', 'Delete']
Properties:
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: ${self:custom.stageConfig.pointInTimeRecoveryEnabled, false}
KeySchema:
- KeyType: HASH
AttributeName: pk
- KeyType: RANGE
AttributeName: sk
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
- AttributeName: data
AttributeType: S
GlobalSecondaryIndexes:
- IndexName: GSI1
KeySchema:
- AttributeName: sk
KeyType: HASH
- AttributeName: data
KeyType: RANGE
Projection:
ProjectionType: ALL
GameTable:
Type: AWS::DynamoDB::Table
# DeletionPolicy: !If [RetainDataResources, 'Retain', 'Delete']
Properties:
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: ${self:custom.stageConfig.pointInTimeRecoveryEnabled, false}
KeySchema:
- KeyType: HASH
AttributeName: id
AttributeDefinitions:
- AttributeName: id
AttributeType: S
WebhookIamRoleLambdaExecution:
Properties:
ManagedPolicyArns:
- 'arn:aws:iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy'
ExpressIamRoleLambdaExecution:
Properties:
ManagedPolicyArns:
- 'arn:aws:iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy'
MonthlyBudget:
Type: AWS::Budgets::Budget
Properties:
Budget:
BudgetName: MonthlyBudget
BudgetType: COST
TimeUnit: MONTHLY
BudgetLimit:
Amount: 5
Unit: USD
NotificationsWithSubscribers:
- Notification:
NotificationType: ACTUAL
ComparisonOperator: GREATER_THAN
Threshold: 50 # 1st alert sent when % of Monthly total is spent.
Subscribers:
- SubscriptionType: EMAIL
Address: ${self:custom.stageConfig.alarms.notificationEmail}
- Notification:
NotificationType: ACTUAL
ComparisonOperator: GREATER_THAN
Threshold: 95 # 2nd alert sent when % of Monthly total is spent.
Subscribers:
- SubscriptionType: EMAIL
Address: ${self:custom.stageConfig.alarms.notificationEmail}
NoNotificationsSentAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
Namespace: civ6-pbc
MetricName: webhook-NOTIFICATION_SENT
AlarmDescription:
No notifications have been sent in the past hour. There may be
something wrong with the Webhook service, or there may simply have been no turns
taken. Review the Webhook Lambda Function's CloudWatch Logs and Metrics.
Threshold: 1
Period: 3600
EvaluationPeriods: 1
ComparisonOperator: LessThanThreshold
AlarmActions:
- !Sub arn:aws:ssm:${aws:region}:${aws:accountId}:opsitem:2#CATEGORY=Availability
TreatMissingData: missing
Statistic: Sum
NoGamesCreatedAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
Namespace: civ6-pbc
MetricName: express-GAME_CREATED
AlarmDescription:
No games have been created in the past day. There may be
something wrong with the game create flow, or there may simply have been no games
created. Review the Express Lambda Function's CloudWatch Logs and Metrics.
Threshold: 1
Period: 86400
EvaluationPeriods: 1
ComparisonOperator: LessThanThreshold
AlarmActions:
- !Sub arn:aws:ssm:${aws:region}:${aws:accountId}:opsitem:2#CATEGORY=Availability
TreatMissingData: missing
Statistic: Sum
# Dashboard:
# Type: AWS::CloudWatch::Dashboard
# Properties:
# DashboardBody: !Sub |
# {
# "start": "-P10D",
# "end": "P0D",
# "widgets": [
# {
# "type": "metric",
# "x": 0,
# "y": 9,
# "width": 6,
# "height": 6,
# "properties": {
# "metrics": [
# [ "civ6-pbc", "webhook-GAME_MARKED_INACTIVE", { "label": "Games marked inactive" } ],
# [ ".", "webhook-SKIP_INACTIVE", { "label": "Inactive games skipped" } ],
# [ "AWS/Lambda", "Errors", "FunctionName", "${WebhookLambdaFunction}", "Resource", "${WebhookLambdaFunction}" ]
# ],
# "view": "timeSeries",
# "stacked": false,
# "region": "${aws:region}",
# "stat": "Sum",
# "period": 3600,
# "title": "Webhook"
# }
# },
# {
# "type": "metric",
# "x": 12,
# "y": 9,
# "width": 3,
# "height": 6,
# "properties": {
# "metrics": [
# [ "AWS/SQS", "ApproximateNumberOfMessagesVisible", "QueueName", "${WebhookSqsQueueDlq.QueueName}", { "label": "Number of messages" } ]
# ],
# "view": "timeSeries",
# "stacked": false,
# "region": "${aws:region}",
# "stat": "Maximum",
# "period": 3600,
# "title": "Webhook DLQ"
# }
# },
# {
# "type": "metric",
# "x": 0,
# "y": 0,
# "width": 7,
# "height": 6,
# "properties": {
# "annotations": {
# "alarms": [
# "${NoNotificationsSentAlarm.Arn}"
# ]
# },
# "yAxis": {
# "left": {
# "min": 0
# }
# },
# "view": "timeSeries",
# "stacked": false,
# "region": "${aws:region}",
# "stat": "Sum",
# "title": "Notifications sent"
# }
# },
# {
# "type": "metric",
# "x": 7,
# "y": 0,
# "width": 3,
# "height": 6,
# "properties": {
# "annotations": {
# "alarms": [
# "${NoGamesCreatedAlarm.Arn}"
# ]
# },
# "yAxis": {
# "left": {
# "min": 0
# }
# },
# "view": "timeSeries",
# "stacked": false,
# "region": "${aws:region}",
# "stat": "Sum",
# "title": "Games created"
# }
# },
# {
# "type": "metric",
# "x": 15,
# "y": 0,
# "width": 9,
# "height": 3,
# "properties": {
# "metrics": [
# [ "civ6-pbc", "webhook-NOTIFICATION_SENT", { "label": "Today", "period": 86400 } ],
# [ "...", { "label": "This week", "period": 604800 } ],
# [ "...", { "label": "This month", "yAxis": "right", "period": 2678400 } ]
# ],
# "view": "singleValue",
# "stacked": false,
# "region": "${aws:region}",
# "stat": "Sum",
# "title": "Notifications sent",
# "start": "-P4W",
# "end": "P0D",
# "period": 2678400
# }
# },
# {
# "type": "log",
# "x": 15,
# "y": 3,
# "width": 3,
# "height": 3,
# "properties": {
# "query": "SOURCE '/aws/lambda/civ6-pbc-production-webhook' | FIELDS gameId\n| STATS count_distinct(gameId) as activeGames",
# "region": "${aws:region}",
# "title": "Active games",
# "view": "singleValue"
# }
# },
# {
# "type": "log",
# "x": 0,
# "y": 12,
# "width": 24,
# "height": 6,
# "properties": {
# "query": "SOURCE '/aws/lambda/${WebhookLambdaFunction}' | SOURCE '/aws/lambda/${ExpressLambdaFunction}' | FIELDS @timestamp, @message\n| SORT @timestamp desc\n| FILTER @message LIKE /Invoke Error/ OR errorType = 'Error' OR level = 'error'\n| DISPLAY @ingestionTime, errorMessage, message",
# "region": "${aws:region}",
# "stacked": false,
# "view": "table"
# }
# }
# ]
# }
Outputs:
GameTableName:
Value: !Ref GameTable
ApiEndpoint:
Value:
Fn::If:
- IsApiCustomDomainEnabled
- https://${self:custom.stageConfig.api.domainName, ''}
- !Sub https://${ApiGatewayRestApi}.execute-api.${aws:region}.amazonaws.com/${self:provider.stage}