diff --git a/cloudformation/lamb-status.yml b/cloudformation/lamb-status.yml index bc77b1c9..3335d6be 100644 --- a/cloudformation/lamb-status.yml +++ b/cloudformation/lamb-status.yml @@ -18,65 +18,117 @@ Mappings: Version: 0.2.1 Resources: LambdaRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "lambda.amazonaws.com" + - lambda.amazonaws.com Action: - - "sts:AssumeRole" - Path: "/" - LambdaRoleInstanceProfile: - Type: "AWS::IAM::InstanceProfile" - Properties: - Path: "/" - Roles: - - Ref: "LambdaRole" - LambStatusIAMPolicy: - Type: "AWS::IAM::Policy" - Properties: - PolicyName: "LambStatus" - PolicyDocument: - Statement: - - Action: - - "logs:CreateLogGroup" - - "logs:CreateLogStream" - - "logs:PutLogEvents" - Effect: "Allow" - Resource: "arn:aws:logs:*:*:*" - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ServiceComponentTable} - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${IncidentTable} - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${IncidentUpdateTable} - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MaintenanceTable} - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MaintenanceUpdateTable} - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MetricsTable} - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${SettingsTable} - Roles: - - Ref: "LambdaRole" + - sts:AssumeRole + Policies: + - PolicyName: CloudWatchLogs + PolicyDocument: + Statement: + - Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Effect: Allow + Resource: arn:aws:logs:*:*:* + - PolicyName: DynamoDB + PolicyDocument: + Statement: + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ServiceComponentTable} + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${IncidentTable} + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${IncidentUpdateTable} + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MaintenanceTable} + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MaintenanceUpdateTable} + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MetricsTable} + - Effect: Allow + Action: dynamodb:* + Resource: !Sub |- + arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${SettingsTable} + - PolicyName: CloudFormation + PolicyDocument: + Statement: + - Effect: Allow + Action: cloudformation:DescribeStacks + Resource: !Sub |- + arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/* + - PolicyName: S3 + PolicyDocument: + Statement: + - Effect: Allow + Action: + - s3:ListBucket + Resource: + - !Sub |- + arn:aws:s3:::${StatusPageS3} + - !Sub |- + arn:aws:s3:::${AdminPageS3} + - Effect: Allow + Action: + - s3:DeleteObject + - s3:PutObject + - s3:GetObject + Resource: + - !Sub |- + arn:aws:s3:::${StatusPageS3}/* + - !Sub |- + arn:aws:s3:::${AdminPageS3}/* + - PolicyName: CloudWatch + PolicyDocument: + Statement: + - Effect: Allow + Action: + - cloudwatch:GetMetricData + - cloudwatch:GetMetricStatistics + - cloudwatch:ListMetrics + Resource: "*" + - PolicyName: Cognito + PolicyDocument: + Statement: + - Effect: Allow + Action: + - cognito-idp:* + - iam:PassRole + Resource: '*' + - PolicyName: APIGateway + PolicyDocument: + Statement: + - Effect: Allow + Action: + - apigateway:POST + Resource: !Sub |- + arn:aws:apigateway:${AWS::Region}::/restapis/${RestApi}/deployments + - PolicyName: SNS + PolicyDocument: + Statement: + - Effect: Allow + Action: sns:publish + Resource: + Ref: IncidentNotificationTopic GetComponentsLambdaFunction: Type: "AWS::Lambda::Function" Properties: @@ -623,7 +675,7 @@ Resources: MemorySize: 128 Role: Fn::GetAtt: - - CognitoHandleFunctionRole + - LambdaRole - Arn Runtime: "nodejs4.3" Timeout: 30 @@ -656,7 +708,7 @@ Resources: MemorySize: 128 Role: Fn::GetAtt: - - "MetricsFunctionRole" + - "LambdaRole" - "Arn" Runtime: "nodejs4.3" Timeout: 30 @@ -788,7 +840,7 @@ Resources: MemorySize: 128 Role: Fn::GetAtt: - - "MetricsFunctionRole" + - "LambdaRole" - "Arn" Runtime: "nodejs4.3" Timeout: 30 @@ -821,7 +873,7 @@ Resources: MemorySize: 128 Role: Fn::GetAtt: - - "MetricsFunctionRole" + - "LambdaRole" - "Arn" Runtime: "nodejs4.3" Timeout: 30 @@ -854,7 +906,7 @@ Resources: MemorySize: 512 Role: Fn::GetAtt: - - "MetricsFunctionRole" + - "LambdaRole" - "Arn" Runtime: "nodejs4.3" Timeout: 60 @@ -869,54 +921,6 @@ Resources: Principal: "events.amazonaws.com" SourceArn: !Sub |- arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/* - MetricsFunctionRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: - - lambda.amazonaws.com - Action: - - sts:AssumeRole - Policies: - - PolicyName: WriteS3 - PolicyDocument: - Statement: - - Effect: Allow - Action: - - s3:DeleteObject - - s3:ListBucket - - s3:PutObject - - s3:GetObject - Resource: arn:aws:s3:::* - - PolicyName: WriteCloudWatchLogs - PolicyDocument: - Statement: - - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: arn:aws:logs:*:*:* - - PolicyName: ReadMetricsTable - PolicyDocument: - Statement: - - Effect: "Allow" - Action: - - "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MetricsTable} - - PolicyName: ReadMetricData - PolicyDocument: - Statement: - - Effect: Allow - Action: - - cloudwatch:GetMetricData - - cloudwatch:GetMetricStatistics - - cloudwatch:ListMetrics - Resource: "*" CollectMetricsDataEvent: Type: AWS::Events::Rule Properties: @@ -930,6 +934,52 @@ Resources: Id: CollectMetricsDataFunction Input: !Sub |- {"StatusPageS3BucketName": "${StatusPageS3}"} + UpdateFeedsLambdaFunction: + Type: "AWS::Lambda::Function" + Properties: + Code: + S3Bucket: !Sub |- + lambstatus-${AWS::Region} + S3Key: !Sub + - fn/${Version}/UpdateFeeds.zip + - Version: + Fn::FindInMap: [ Constants, LambStatus, Version ] + Description: "Update feeds" + # The prefix of function name must be stack name + FunctionName: !Sub |- + ${AWS::StackName}-UpdateFeeds + Handler: "_apex_index.handle" + MemorySize: 128 + Role: + Fn::GetAtt: + - "LambdaRole" + - "Arn" + Runtime: "nodejs4.3" + Timeout: 30 + UpdateFeedsLambdaInvokePermission: + Type: "AWS::Lambda::Permission" + Properties: + FunctionName: + Fn::GetAtt: + - "UpdateFeedsLambdaFunction" + - "Arn" + Action: "lambda:InvokeFunction" + Principal: "sns.amazonaws.com" + SourceArn: + Ref: "IncidentNotificationTopic" + IncidentNotificationTopic: + Type: "AWS::SNS::Topic" + Properties: + TopicName: !Sub |- + ${AWS::StackName}-IncidentNotification + IncidentSubscriptionByUpdateFeeds: + Type: "AWS::SNS::Subscription" + Properties: + Endpoint: !Sub |- + ${UpdateFeedsLambdaFunction.Arn} + Protocol: "lambda" + TopicArn: + Ref: "IncidentNotificationTopic" ApiDeployment: Type: "AWS::ApiGateway::Deployment" Properties: @@ -2656,37 +2706,6 @@ Resources: ProvisionedThroughput: ReadCapacityUnits: "1" WriteCapacityUnits: "1" - S3HandleFunctionRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: - - lambda.amazonaws.com - Action: - - sts:AssumeRole - Policies: - - PolicyName: WriteS3 - PolicyDocument: - Statement: - - Effect: Allow - Action: - - s3:DeleteObject - - s3:ListBucket - - s3:PutObject - - s3:GetObject - Resource: arn:aws:s3:::* - - PolicyName: WriteCloudWatchLogs - PolicyDocument: - Statement: - - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: arn:aws:logs:*:*:* S3PutObjectFunction: Type: AWS::Lambda::Function Properties: @@ -2704,7 +2723,7 @@ Resources: Handler: "_apex_index.handle" Role: Fn::GetAtt: - - S3HandleFunctionRole + - LambdaRole - Arn Runtime: nodejs4.3 Timeout: 30 @@ -2725,46 +2744,10 @@ Resources: Handler: "_apex_index.handle" Role: Fn::GetAtt: - - S3HandleFunctionRole + - LambdaRole - Arn Runtime: nodejs4.3 Timeout: 30 - CognitoHandleFunctionRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: - - lambda.amazonaws.com - Action: - - sts:AssumeRole - Policies: - - PolicyName: WriteCloudWatchLogs - PolicyDocument: - Statement: - - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: arn:aws:logs:*:*:* - - PolicyName: CreateUserPool - PolicyDocument: - Statement: - - Effect: Allow - Action: - - cognito-idp:* - - iam:PassRole - Resource: '*' - - PolicyName: HandleSettings - PolicyDocument: - Statement: - - Effect: "Allow" - Action: "dynamodb:*" - Resource: !Sub |- - arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${SettingsTable} CognitoCreateUserPoolFunction: Type: AWS::Lambda::Function Properties: @@ -2782,7 +2765,7 @@ Resources: Handler: "_apex_index.handle" Role: Fn::GetAtt: - - CognitoHandleFunctionRole + - LambdaRole - Arn Runtime: nodejs4.3 Timeout: 30 @@ -2803,7 +2786,7 @@ Resources: Handler: "_apex_index.handle" Role: Fn::GetAtt: - - CognitoHandleFunctionRole + - LambdaRole - Arn Runtime: nodejs4.3 Timeout: 30 @@ -2824,7 +2807,7 @@ Resources: Handler: "_apex_index.handle" Role: Fn::GetAtt: - - CognitoHandleFunctionRole + - LambdaRole - Arn Runtime: nodejs4.3 Timeout: 30 @@ -2845,39 +2828,10 @@ Resources: Handler: "_apex_index.handle" Role: Fn::GetAtt: - - APIGatewayHandleFunctionRole + - LambdaRole - Arn Runtime: nodejs4.3 Timeout: 30 - APIGatewayHandleFunctionRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: - - lambda.amazonaws.com - Action: - - sts:AssumeRole - Policies: - - PolicyName: WriteCloudWatchLogs - PolicyDocument: - Statement: - - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: arn:aws:logs:*:*:* - - PolicyName: APIGateway - PolicyDocument: - Statement: - - Effect: Allow - Action: - - apigateway:POST - Resource: !Sub |- - arn:aws:apigateway:${AWS::Region}::/restapis/${RestApi}/deployments DBCreateItemsLambdaFunction: Type: "AWS::Lambda::Function" Properties: @@ -2896,7 +2850,7 @@ Resources: MemorySize: 128 Role: Fn::GetAtt: - - CognitoHandleFunctionRole + - LambdaRole - Arn Runtime: "nodejs4.3" Timeout: 30 @@ -3049,18 +3003,6 @@ Outputs: LambdaRoleArn: Value: !GetAtt LambdaRole.Arn - MetricsFunctionRoleArn: - Value: - !GetAtt MetricsFunctionRole.Arn - S3HandleFunctionRoleArn: - Value: - !GetAtt S3HandleFunctionRole.Arn - CognitoHandleFunctionRoleArn: - Value: - !GetAtt CognitoHandleFunctionRole.Arn - APIGatewayHandleFunctionRoleArn: - Value: - !GetAtt APIGatewayHandleFunctionRole.Arn AdminPageS3BucketURL: Value: Fn::GetAtt: @@ -3097,3 +3039,6 @@ Outputs: Version: Value: Fn::FindInMap: [ Constants, LambStatus, Version ] + IncidentNotificationTopic: + Value: + Ref: "IncidentNotificationTopic" diff --git a/packages/frontend/src/components/statusPage/History/History.js b/packages/frontend/src/components/statusPage/History/History.js index 80448552..fdbbab92 100644 --- a/packages/frontend/src/components/statusPage/History/History.js +++ b/packages/frontend/src/components/statusPage/History/History.js @@ -2,6 +2,7 @@ import React, { PropTypes } from 'react' import classnames from 'classnames' import ModestLink from 'components/common/ModestLink' import Title from 'components/statusPage/Title' +import SubscribeButton from 'components/statusPage/SubscribeButton' import IncidentItem from 'components/statusPage/IncidentItem' import MaintenanceItem from 'components/statusPage/MaintenanceItem' import { getDateTimeFormat } from 'utils/datetime' @@ -91,16 +92,21 @@ export default class History extends React.Component { const events = incidents.concat(maintenances) const eventsByMonth = this.renderEventsByMonth(events) - return (
- - <div className='mdl-cell mdl-cell--12-col'> - <h4>Incident History</h4> - </div> - <div className='mdl-cell mdl-cell--12-col mdl-list'> - {eventsByMonth} + return ( + <div className={classnames(classes.layout, 'mdl-grid')} + style={{ opacity: this.state.isFetching ? 0.5 : 1 }}> + <div className={classnames(classes.top)}> + <Title service_name={settings.serviceName} /> + <SubscribeButton /> + </div> + <div className='mdl-cell mdl-cell--12-col'> + <h4>Incident History</h4> + </div> + <div className='mdl-cell mdl-cell--12-col mdl-list'> + {eventsByMonth} + </div> + <ModestLink link='/' text='Current Incidents' /> </div> - <ModestLink link='/' text='Current Incidents' /> - </div>) + ) } } diff --git a/packages/frontend/src/components/statusPage/History/History.scss b/packages/frontend/src/components/statusPage/History/History.scss index 8343c934..e5efb0b1 100644 --- a/packages/frontend/src/components/statusPage/History/History.scss +++ b/packages/frontend/src/components/statusPage/History/History.scss @@ -1,3 +1,9 @@ +.top { + display: flex; + justify-content: space-between; + width: 100%; +} + .date_item { height: auto; } diff --git a/packages/frontend/src/components/statusPage/Statuses/Statuses.js b/packages/frontend/src/components/statusPage/Statuses/Statuses.js index 35c3d9da..281593bb 100644 --- a/packages/frontend/src/components/statusPage/Statuses/Statuses.js +++ b/packages/frontend/src/components/statusPage/Statuses/Statuses.js @@ -4,6 +4,7 @@ import Button from 'components/common/Button' import ModestLink from 'components/common/ModestLink' import MetricsGraph from 'components/common/MetricsGraph' import Title from 'components/statusPage/Title' +import SubscribeButton from 'components/statusPage/SubscribeButton' import Components from 'components/statusPage/Components' import Incidents from 'components/statusPage/Incidents' import ScheduledMaintenances from 'components/statusPage/ScheduledMaintenances' @@ -95,15 +96,20 @@ export default class Statuses extends React.Component { ) } - return (<div className={classnames(classes.layout, 'mdl-grid')} - style={{ opacity: this.state.isFetching ? 0.5 : 1 }}> - <Title service_name={settings.serviceName} /> - {components} - {maintenances} - {metricsTitle} - {metricsContent} - {incidents} - <ModestLink link='/history' text='Incident History' /> - </div>) + return ( + <div className={classnames(classes.layout, 'mdl-grid')} + style={{ opacity: this.state.isFetching ? 0.5 : 1 }}> + <div className={classnames(classes.top)}> + <Title service_name={settings.serviceName} /> + <SubscribeButton /> + </div> + {components} + {maintenances} + {metricsTitle} + {metricsContent} + {incidents} + <ModestLink link='/history' text='Incident History' /> + </div> + ) } } diff --git a/packages/frontend/src/components/statusPage/Statuses/Statuses.scss b/packages/frontend/src/components/statusPage/Statuses/Statuses.scss index 53004346..1e3bce5c 100644 --- a/packages/frontend/src/components/statusPage/Statuses/Statuses.scss +++ b/packages/frontend/src/components/statusPage/Statuses/Statuses.scss @@ -1,3 +1,9 @@ +.top { + display: flex; + justify-content: space-between; + width: 100%; +} + .title { margin-top: 32px; margin-bottom: -8px; diff --git a/packages/frontend/src/components/statusPage/SubscribeButton/SubscribeButton.scss b/packages/frontend/src/components/statusPage/SubscribeButton/SubscribeButton.scss new file mode 100644 index 00000000..cdefeeef --- /dev/null +++ b/packages/frontend/src/components/statusPage/SubscribeButton/SubscribeButton.scss @@ -0,0 +1,10 @@ +.rss-icon { + margin-top: 28px; + margin-bottom: 16px; + height: 24px; + line-height: 24px; + + border-radius: 20%; + background-color: #f57c00; + color: #fff; +} diff --git a/packages/frontend/src/components/statusPage/SubscribeButton/index.js b/packages/frontend/src/components/statusPage/SubscribeButton/index.js new file mode 100644 index 00000000..047d7ab1 --- /dev/null +++ b/packages/frontend/src/components/statusPage/SubscribeButton/index.js @@ -0,0 +1,13 @@ +import React from 'react' +import classnames from 'classnames' +import classes from './SubscribeButton.scss' + +export default class SubscribeButton extends React.Component { + render () { + return ( + <a href='history.rss'> + <i className={classnames(classes['rss-icon'], 'material-icons')}>rss_feed</i> + </a> + ) + } +} diff --git a/packages/lambda/bin/setup-apex.js b/packages/lambda/bin/setup-apex.js index ac1eaf97..5f9a42fc 100644 --- a/packages/lambda/bin/setup-apex.js +++ b/packages/lambda/bin/setup-apex.js @@ -49,27 +49,6 @@ const createFunctionJSON = (role, timeout, memory, targetDirs) => { console.log(`${dir}/function.json created`) }) } -const metricsFunctionRoleArn = getArn(awsResourceIDs, 'MetricsFunctionRoleArn') -createFunctionJSON(metricsFunctionRoleArn, 60, 512, [ +createFunctionJSON(lambdaRoleArn, 60, 512, [ buildDir + '/functions/CollectMetricsData' ]) -createFunctionJSON(metricsFunctionRoleArn, 30, 128, [ - buildDir + '/functions/GetExternalMetrics' -]) -const s3HandleFunctionRoleArn = getArn(awsResourceIDs, 'S3HandleFunctionRoleArn') -createFunctionJSON(s3HandleFunctionRoleArn, 30, 128, [ - buildDir + '/functions/S3PutObject', - buildDir + '/functions/S3SyncObjects' -]) -const cognitoHandleFunctionRoleArn = getArn(awsResourceIDs, 'CognitoHandleFunctionRoleArn') -createFunctionJSON(cognitoHandleFunctionRoleArn, 30, 128, [ - buildDir + '/functions/CognitoCreateUser', - buildDir + '/functions/CognitoCreateUserPool', - buildDir + '/functions/CognitoCreateUserPoolClient', - buildDir + '/functions/DBCreateItems', - buildDir + '/functions/PatchSettings' -]) -const apiGatewayHandleFunctionRoleArn = getArn(awsResourceIDs, 'APIGatewayHandleFunctionRoleArn') -createFunctionJSON(apiGatewayHandleFunctionRoleArn, 30, 128, [ - buildDir + '/functions/APIGatewayDeploy' -]) diff --git a/packages/lambda/config/webpack.config.es6.js b/packages/lambda/config/webpack.config.es6.js index 4c54d7c3..510540d5 100644 --- a/packages/lambda/config/webpack.config.es6.js +++ b/packages/lambda/config/webpack.config.es6.js @@ -128,6 +128,10 @@ export default { DBCreateItems: [ 'babel-polyfill', './src/api/dbCreateItems/index.js' + ], + UpdateFeeds: [ + 'babel-polyfill', + './src/api/updateFeeds/index.js' ] }, output: { diff --git a/packages/lambda/package.json b/packages/lambda/package.json index 07884f45..1a8cbb60 100644 --- a/packages/lambda/package.json +++ b/packages/lambda/package.json @@ -39,6 +39,7 @@ "json-loader": "^0.5.4", "mime": "^1.3.4", "mkdirp": "^0.5.1", + "moment": "^2.18.1", "rimraf": "^2.5.4", "verror": "^1.8.1", "webpack": "^1.13.2" diff --git a/packages/lambda/src/api/deleteIncidents/index.js b/packages/lambda/src/api/deleteIncidents/index.js index 18215a9a..d33ad009 100644 --- a/packages/lambda/src/api/deleteIncidents/index.js +++ b/packages/lambda/src/api/deleteIncidents/index.js @@ -1,10 +1,13 @@ import { Incidents } from 'model/incidents' +import SNS from 'aws/sns' export async function handle (event, context, callback) { try { const incidents = new Incidents() const incident = await incidents.lookup(event.params.incidentid) await incident.delete() + + await new SNS().notifyIncident(incident) } catch (error) { console.log(error.message) console.log(error.stack) diff --git a/packages/lambda/src/api/deleteMaintenances/index.js b/packages/lambda/src/api/deleteMaintenances/index.js index 50a588d1..1e419e4b 100644 --- a/packages/lambda/src/api/deleteMaintenances/index.js +++ b/packages/lambda/src/api/deleteMaintenances/index.js @@ -1,10 +1,13 @@ import { Maintenances } from 'model/maintenances' +import SNS from 'aws/sns' export async function handle (event, context, callback) { try { const maintenances = new Maintenances() const maintenance = await maintenances.lookup(event.params.maintenanceid) await maintenance.delete() + + await new SNS().notifyIncident(maintenance) } catch (error) { console.log(error.message) console.log(error.stack) diff --git a/packages/lambda/src/api/patchIncidents/index.js b/packages/lambda/src/api/patchIncidents/index.js index 971be738..39c1664a 100644 --- a/packages/lambda/src/api/patchIncidents/index.js +++ b/packages/lambda/src/api/patchIncidents/index.js @@ -1,4 +1,5 @@ import { Incident } from 'model/incidents' +import SNS from 'aws/sns' export async function handle (event, context, callback) { try { @@ -7,6 +8,8 @@ export async function handle (event, context, callback) { await incident.validate() await incident.save() + await new SNS().notifyIncident(incident) + const obj = incident.objectify() const comps = obj.components delete obj.components diff --git a/packages/lambda/src/api/patchMaintenances/index.js b/packages/lambda/src/api/patchMaintenances/index.js index 9dda071e..59a21402 100644 --- a/packages/lambda/src/api/patchMaintenances/index.js +++ b/packages/lambda/src/api/patchMaintenances/index.js @@ -1,4 +1,5 @@ import { Maintenance } from 'model/maintenances' +import SNS from 'aws/sns' export async function handle (event, context, callback) { try { @@ -8,6 +9,8 @@ export async function handle (event, context, callback) { await maintenance.validate() await maintenance.save() + await new SNS().notifyIncident(maintenance) + const obj = maintenance.objectify() const comps = obj.components delete obj.components diff --git a/packages/lambda/src/api/postIncidents/index.js b/packages/lambda/src/api/postIncidents/index.js index 9829cb40..1a45b33c 100644 --- a/packages/lambda/src/api/postIncidents/index.js +++ b/packages/lambda/src/api/postIncidents/index.js @@ -1,4 +1,5 @@ import { Incident } from 'model/incidents' +import SNS from 'aws/sns' export async function handle (event, context, callback) { try { @@ -7,6 +8,8 @@ export async function handle (event, context, callback) { await incident.validate() await incident.save() + await new SNS().notifyIncident(incident) + const obj = incident.objectify() const comps = obj.components delete obj.components diff --git a/packages/lambda/src/api/postMaintenances/index.js b/packages/lambda/src/api/postMaintenances/index.js index 2ab75c80..06136451 100644 --- a/packages/lambda/src/api/postMaintenances/index.js +++ b/packages/lambda/src/api/postMaintenances/index.js @@ -1,4 +1,5 @@ import { Maintenance } from 'model/maintenances' +import SNS from 'aws/sns' export async function handle (event, context, callback) { try { @@ -7,6 +8,8 @@ export async function handle (event, context, callback) { await maintenance.validate() await maintenance.save() + await new SNS().notifyIncident(maintenance) + const obj = maintenance.objectify() const comps = obj.components delete obj.components diff --git a/packages/lambda/src/api/updateFeeds/index.js b/packages/lambda/src/api/updateFeeds/index.js new file mode 100644 index 00000000..c5b90bc2 --- /dev/null +++ b/packages/lambda/src/api/updateFeeds/index.js @@ -0,0 +1,87 @@ +import Feed from 'feed' +import { Settings } from 'model/settings' +import { Incidents } from 'model/incidents' +import { Maintenances } from 'model/maintenances' +import S3 from 'aws/s3' +import CloudFormation from 'aws/cloudFormation' +import { stackName } from 'utils/const' +import { getDateTimeFormat } from 'utils/datetime' + +export async function handle (event, context, callback) { + try { + const settings = new Settings() + const statusPageURL = await settings.getStatusPageURL() + const serviceName = await settings.getServiceName() + const feed = new Feed({ + id: `tag:${statusPageURL},2017:/history`, + link: statusPageURL, + title: `${serviceName} Status - Incident History`, + author: { + name: serviceName + } + }) + + let events = (await new Incidents().all()).concat(await new Maintenances().all()) + events.sort(latestToOldest) + const maxItems = 25 + for (let i = 0; i < maxItems; i++) { + feed.addItem(await buildItem(events[i], statusPageURL)) + } + + const { AWS_REGION: region } = process.env + const bucket = await new CloudFormation(stackName).getStatusPageBucketName() + const s3 = new S3() + await s3.putObject(region, bucket, 'history.atom', feed.atom1()) + await s3.putObject(region, bucket, 'history.rss', feed.rss2()) + callback(null) + } catch (error) { + console.log(error.message) + console.log(error.stack) + callback('Error: failed to update the feeds') + } +} + +const latestToOldest = (a, b) => { + if (a.updatedAt < b.updatedAt) return 1 + else if (b.updatedAt < a.updatedAt) return -1 + return 0 +} + +const buildItem = async (event, statusPageURL) => { + let id, link, eventUpdates + if (event.hasOwnProperty('incidentID')) { + const incidentUpdates = await event.getIncidentUpdates() + incidentUpdates.sort(latestToOldest) + + id = `tag:${statusPageURL},2017:Incident/${event.incidentID}` + link = `${statusPageURL}/incidents/${event.incidentID}` + eventUpdates = incidentUpdates.map(update => { + update.status = update.incidentStatus + return update + }) + } else if (event.hasOwnProperty('maintenanceID')) { + const maintenanceUpdates = await event.getMaintenanceUpdates() + maintenanceUpdates.sort(latestToOldest) + + id = `tag:${statusPageURL},2017:Maintenance/${event.maintenanceID}` + link = `${statusPageURL}/maintenances/${event.maintenanceID}` + eventUpdates = maintenanceUpdates.map(update => { + update.status = update.maintenanceStatus + return update + }) + } else { + throw new Error('Unknown event: ', event) + } + + const content = eventUpdates.map(update => { + return `<p><small>${getDateTimeFormat(update.updatedAt)}</small><br><strong>${update.status}</strong> - ${update.message}</p>` + }).join('') + return { + id, + link, + content, + published: new Date(eventUpdates[0].updatedAt), + date: new Date(event.updatedAt), + title: event.name + } +} diff --git a/packages/lambda/src/aws/cloudFormation.js b/packages/lambda/src/aws/cloudFormation.js new file mode 100644 index 00000000..77f299c7 --- /dev/null +++ b/packages/lambda/src/aws/cloudFormation.js @@ -0,0 +1,62 @@ +import AWS from 'aws-sdk' + +export default class CloudFormation { + constructor (stackName) { + const { AWS_REGION: region } = process.env + this.cloudFormation = new AWS.CloudFormation({ region }) + this.stackName = stackName + } + + describe () { + const params = { StackName: this.stackName } + return new Promise((resolve, reject) => { + this.cloudFormation.describeStacks(params, (error, data) => { + if (error) { + return reject(error) + } + if (!data) { + return reject(new Error('describeStacks returned no data')) + } + const { Stacks: stacks } = data + if (!stacks || stacks.length !== 1) { + return reject(new Error('describeStacks unexpected number of stacks')) + } + const stack = stacks[0] + resolve(stack) + }) + }) + } + + async getOutputValue (key) { + if (!this.stack) { + this.stack = await this.describe() + } + + let value + this.stack.Outputs.some(output => { + if (output.OutputKey === key) { + value = output.OutputValue + return true + } + }) + if (!value) { + throw new Error(`failed to get output value (key: ${key}`) + } + return value + } + + async getAdminPageBucketName () { + const key = 'AdminPageS3BucketName' + return await this.getOutputValue(key) + } + + async getStatusPageBucketName () { + const key = 'StatusPageS3BucketName' + return await this.getOutputValue(key) + } + + async getIncidentNotificationTopic () { + const key = 'IncidentNotificationTopic' + return await this.getOutputValue(key) + } +} diff --git a/packages/lambda/src/aws/sns.js b/packages/lambda/src/aws/sns.js new file mode 100644 index 00000000..6b59f3c2 --- /dev/null +++ b/packages/lambda/src/aws/sns.js @@ -0,0 +1,31 @@ +import AWS from 'aws-sdk' +import CloudFormation from 'aws/cloudFormation' +import { stackName } from 'utils/const' + +export default class SNS { + constructor () { + const { AWS_REGION: region } = process.env + this.sns = new AWS.SNS({ apiVersion: '2010-03-31', region }) + } + + async notifyIncident (message) { + const topic = await new CloudFormation(stackName).getIncidentNotificationTopic() + return await this.publish(topic, message) + } + + publish (topic, message) { + const params = { + Message: JSON.stringify({default: JSON.stringify(message)}), + MessageStructure: 'json', + TopicArn: topic + } + return new Promise((resolve, reject) => { + this.sns.publish(params, (error, data) => { + if (error) { + return reject(error) + } + resolve(data) + }) + }) + } +} diff --git a/packages/lambda/src/utils/const.js b/packages/lambda/src/utils/const.js index 5f2c3cb4..f1ed4de5 100644 --- a/packages/lambda/src/utils/const.js +++ b/packages/lambda/src/utils/const.js @@ -1,4 +1,4 @@ -const stackName = process.env.AWS_LAMBDA_FUNCTION_NAME.replace(/-[^-]*$/, '') +export const stackName = process.env.AWS_LAMBDA_FUNCTION_NAME.replace(/-[^-]*$/, '') export const ServiceComponentTable = `${stackName}-ServiceComponentTable` export const IncidentTable = `${stackName}-IncidentTable` export const IncidentUpdateTable = `${stackName}-IncidentUpdateTable` diff --git a/packages/lambda/src/utils/datetime.js b/packages/lambda/src/utils/datetime.js new file mode 100644 index 00000000..2f5e3f53 --- /dev/null +++ b/packages/lambda/src/utils/datetime.js @@ -0,0 +1,5 @@ +import moment from 'moment' + +export const getDateTimeFormat = (datetime, fmt = 'MMM D, YYYY, HH:mm UTC') => { + return moment(datetime).format(fmt) +} diff --git a/packages/lambda/test/api/deleteMaintenances/index.js b/packages/lambda/test/api/deleteMaintenances/index.js index 2dcd5b5b..70e20202 100644 --- a/packages/lambda/test/api/deleteMaintenances/index.js +++ b/packages/lambda/test/api/deleteMaintenances/index.js @@ -2,28 +2,34 @@ import assert from 'assert' import sinon from 'sinon' import { handle } from 'api/deleteMaintenances' import { Maintenances, Maintenance } from 'model/maintenances' +import SNS from 'aws/sns' describe('deleteMaintenances', () => { afterEach(() => { Maintenance.prototype.delete.restore() Maintenances.prototype.lookup.restore() + SNS.prototype.notifyIncident.restore() }) it('should delete the maintenance', async () => { const maint = new Maintenance('1', undefined, undefined, undefined, undefined, undefined, [], '1') const lookupStub = sinon.stub(Maintenances.prototype, 'lookup').returns(maint) const deleteStub = sinon.stub(Maintenance.prototype, 'delete').returns('') + const snsStub = sinon.stub(SNS.prototype, 'notifyIncident').returns() await handle({ params: { maintenanceid: '1' } }, null, (error) => { assert(error === null) }) assert(lookupStub.calledOnce) assert(deleteStub.calledOnce) + assert(snsStub.calledOnce) }) it('should return error on exception thrown', async () => { sinon.stub(Maintenances.prototype, 'lookup').throws() - sinon.stub(Maintenance.prototype, 'delete').throws() + sinon.stub(Maintenance.prototype, 'delete').returns() + sinon.stub(SNS.prototype, 'notifyIncident').returns() + return await handle({ components: [] }, null, (error, result) => { assert(error.match(/Error/)) }) diff --git a/packages/lambda/test/api/patchMaintenances/index.js b/packages/lambda/test/api/patchMaintenances/index.js index 845ce5b4..e85edc8b 100644 --- a/packages/lambda/test/api/patchMaintenances/index.js +++ b/packages/lambda/test/api/patchMaintenances/index.js @@ -2,16 +2,19 @@ import assert from 'assert' import sinon from 'sinon' import { handle } from 'api/patchMaintenances' import { Maintenance } from 'model/maintenances' +import SNS from 'aws/sns' describe('patchMaintenances', () => { afterEach(() => { Maintenance.prototype.validate.restore() Maintenance.prototype.save.restore() + SNS.prototype.notifyIncident.restore() }) it('should update the maintenance', async () => { const validateStub = sinon.stub(Maintenance.prototype, 'validate').returns('') const saveStub = sinon.stub(Maintenance.prototype, 'save').returns('') + const snsStub = sinon.stub(SNS.prototype, 'notifyIncident').returns() await handle({ params: { maintenanceid: '1' }, body: { components: [] } }, null, (error, result) => { assert(error === null) @@ -21,11 +24,14 @@ describe('patchMaintenances', () => { }) assert(validateStub.calledOnce) assert(saveStub.calledOnce) + assert(snsStub.calledOnce) }) it('should return error on exception thrown', async () => { sinon.stub(Maintenance.prototype, 'validate').throws() - sinon.stub(Maintenance.prototype, 'save').throws() + sinon.stub(Maintenance.prototype, 'save').returns() + sinon.stub(SNS.prototype, 'notifyIncident').returns() + return await handle({ params: { maintenanceid: '1' }, body: { components: [] } }, null, (error, result) => { assert(error.match(/Error/)) }) diff --git a/packages/lambda/test/api/postMaintenances/index.js b/packages/lambda/test/api/postMaintenances/index.js index 398ada03..3fa4b97d 100644 --- a/packages/lambda/test/api/postMaintenances/index.js +++ b/packages/lambda/test/api/postMaintenances/index.js @@ -2,16 +2,19 @@ import assert from 'assert' import sinon from 'sinon' import { handle } from 'api/postMaintenances' import { Maintenance } from 'model/maintenances' +import SNS from 'aws/sns' describe('postMaintenances', () => { afterEach(() => { Maintenance.prototype.validate.restore() Maintenance.prototype.save.restore() + SNS.prototype.notifyIncident.restore() }) it('should update the maintenance', async () => { const validateStub = sinon.stub(Maintenance.prototype, 'validate').returns('') const saveStub = sinon.stub(Maintenance.prototype, 'save').returns('') + const snsStub = sinon.stub(SNS.prototype, 'notifyIncident').returns() await handle({ components: [] }, null, (error, result) => { assert(error === null) @@ -21,11 +24,14 @@ describe('postMaintenances', () => { }) assert(validateStub.calledOnce) assert(saveStub.calledOnce) + assert(snsStub.calledOnce) }) it('should return error on exception thrown', async () => { sinon.stub(Maintenance.prototype, 'validate').throws() - sinon.stub(Maintenance.prototype, 'save').throws() + sinon.stub(Maintenance.prototype, 'save').returns() + sinon.stub(SNS.prototype, 'notifyIncident').returns() + return await handle({ components: [] }, null, (error, result) => { assert(error.match(/Error/)) })