From 40229fb3d342e62f2eb47ee3c7d5f85b45d39976 Mon Sep 17 00:00:00 2001 From: Ilya Valasiuk Date: Tue, 16 Mar 2021 11:53:21 +0300 Subject: [PATCH 1/2] task2: added s3 bucket and cloudfront distribution --- .../serverless-single-page-app-plugin.js | 151 ++++++++++++++++++ package.json | 37 +++-- serverless.yml | 125 +++++++++++++++ 3 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 .serverless_plugins/serverless-single-page-app-plugin.js create mode 100644 serverless.yml diff --git a/.serverless_plugins/serverless-single-page-app-plugin.js b/.serverless_plugins/serverless-single-page-app-plugin.js new file mode 100644 index 000000000..ca7cee5b0 --- /dev/null +++ b/.serverless_plugins/serverless-single-page-app-plugin.js @@ -0,0 +1,151 @@ +'use strict'; + +const spawnSync = require('child_process').spawnSync; + +class ServerlessPlugin { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options; + this.commands = { + syncToS3: { + usage: 'Deploys the `app` directory to your bucket', + lifecycleEvents: [ + 'sync', + ], + }, + domainInfo: { + usage: 'Fetches and prints out the deployed CloudFront domain names', + lifecycleEvents: [ + 'domainInfo', + ], + }, + invalidateCloudFrontCache: { + usage: 'Invalidates CloudFront cache', + lifecycleEvents: [ + 'invalidateCache', + ], + }, + }; + + this.hooks = { + 'syncToS3:sync': this.syncDirectory.bind(this), + 'domainInfo:domainInfo': this.domainInfo.bind(this), + 'invalidateCloudFrontCache:invalidateCache': this.invalidateCache.bind( + this, + ), + }; + } + + runAwsCommand(args) { + let command = 'aws'; + if (this.serverless.variables.service.provider.region) { + command = `${command} --region ${this.serverless.variables.service.provider.region}`; + } + if (this.serverless.variables.service.provider.profile) { + command = `${command} --profile ${this.serverless.variables.service.provider.profile}`; + } + const result = spawnSync(command, args, { shell: true }); + const stdout = result.stdout.toString(); + const sterr = result.stderr.toString(); + if (stdout) { + this.serverless.cli.log(stdout); + } + if (sterr) { + this.serverless.cli.log(sterr); + } + + return { stdout, sterr }; + } + + // syncs the `app` directory to the provided bucket + syncDirectory() { + const s3Bucket = this.serverless.variables.service.custom.s3Bucket; + const buildFolder = this.serverless.variables.service.custom.client.distributionFolder; + const args = [ + 's3', + 'sync', + `${buildFolder}/`, + `s3://${s3Bucket}/`, + '--delete', + ]; + const { sterr } = this.runAwsCommand(args); + if (!sterr) { + this.serverless.cli.log('Successfully synced to the S3 bucket'); + } else { + throw new Error('Failed syncing to the S3 bucket'); + } + } + + // fetches the domain name from the CloudFront outputs and prints it out + async domainInfo() { + const provider = this.serverless.getProvider('aws'); + const stackName = provider.naming.getStackName(this.options.stage); + const result = await provider.request( + 'CloudFormation', + 'describeStacks', + { StackName: stackName }, + this.options.stage, + this.options.region, + ); + + const outputs = result.Stacks[0].Outputs; + const output = outputs.find( + entry => entry.OutputKey === 'WebAppCloudFrontDistributionOutput', + ); + + if (output && output.OutputValue) { + this.serverless.cli.log(`Web App Domain: ${output.OutputValue}`); + return output.OutputValue; + } + + this.serverless.cli.log('Web App Domain: Not Found'); + const error = new Error('Could not extract Web App Domain'); + throw error; + } + + async invalidateCache() { + const provider = this.serverless.getProvider('aws'); + + const domain = await this.domainInfo(); + + const result = await provider.request( + 'CloudFront', + 'listDistributions', + {}, + this.options.stage, + this.options.region, + ); + + const distributions = result.DistributionList.Items; + const distribution = distributions.find( + entry => entry.DomainName === domain, + ); + + if (distribution) { + this.serverless.cli.log( + `Invalidating CloudFront distribution with id: ${distribution.Id}`, + ); + const args = [ + 'cloudfront', + 'create-invalidation', + '--distribution-id', + distribution.Id, + '--paths', + '"/*"', + ]; + const { sterr } = this.runAwsCommand(args); + if (!sterr) { + this.serverless.cli.log('Successfully invalidated CloudFront cache'); + } else { + throw new Error('Failed invalidating CloudFront cache'); + } + } else { + const message = `Could not find distribution with domain ${domain}`; + const error = new Error(message); + this.serverless.cli.log(message); + throw error; + } + } +} + +module.exports = ServerlessPlugin; \ No newline at end of file diff --git a/package.json b/package.json index 4acf8a0e8..09ea4ff74 100755 --- a/package.json +++ b/package.json @@ -5,34 +5,47 @@ "scripts": { "start": "react-scripts start", "build": "react-scripts build", + "client:deploy": "sls client deploy --no-config-change --no-policy-change --no-cors-change", + "client:deploy:nc": "npm run client:deploy -- --no-confirm", + "client:build:deploy": "npm run build && npm run client:deploy", + "client:build:deploy:nc": "npm run build && npm run client:deploy:nc", + "cloudfront:setup": "sls deploy", + "cloudfront:domainInfo": "sls domainInfo", + "cloudfront:invalidateCache": "sls invalidateCloudFrontCache", + "cloudfront:build:deploy": "npm run client:build:deploy && npm run cloudfront:invalidateCache", + "cloudfront:build:deploy:nc": "npm run client:build:deploy:nc && npm run cloudfront:invalidateCache", + "cloudfront:update:build:deploy": "npm run cloudfront:setup && npm run cloudfront:build:deploy", + "cloudfront:update:build:deploy:nc": "npm run cloudfront:setup && npm run cloudfront:build:deploy:nc", "test": "react-scripts test", "eject": "react-scripts eject" }, "dependencies": { - "axios": "^0.19.2", - "formik": "^2.1.5", - "formik-material-ui": "^2.0.1", - "yup": "^0.29.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", + "@reduxjs/toolkit": "^1.2.5", + "@types/lodash": "^4.14.158", "@types/node": "^12.0.0", "@types/react": "^16.9.43", "@types/react-dom": "^16.9.8", + "@types/react-redux": "^7.1.7", "@types/react-router-dom": "^5.1.5", "@types/yup": "^0.29.3", + "axios": "^0.19.2", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.2", + "enzyme-to-json": "^3.4.4", + "formik": "^2.1.5", + "formik-material-ui": "^2.0.1", + "lodash": "^4.17.19", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-redux": "^7.2.0", "react-router-dom": "^5.2.0", "react-scripts": "3.4.1", + "serverless": "^2.29.0", + "serverless-finch": "^2.6.0", "typescript": "~3.7.2", - "@reduxjs/toolkit": "^1.2.5", - "react-redux": "^7.2.0", - "@types/react-redux": "^7.1.7", - "@types/lodash": "^4.14.158", - "lodash": "^4.17.19", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.2", - "enzyme-to-json": "^3.4.4" + "yup": "^0.29.1" }, "devDependencies": { "@testing-library/jest-dom": "^4.2.4", diff --git a/serverless.yml b/serverless.yml new file mode 100644 index 000000000..a56f10f5f --- /dev/null +++ b/serverless.yml @@ -0,0 +1,125 @@ +service: node-in-aws-web + +frameworkVersion: '2' + +provider: + name: aws + runtime: nodejs12.x + # setup profile for AWS CLI. + # profile: node-aws + +plugins: + - serverless-finch + - serverless-single-page-app-plugin + +custom: + client: + bucketName: node-in-aws-web-bucket + distributionFolder: build + s3BucketName: ${self:custom.client.bucketName} + + ## Serverless-single-page-app-plugin configuration: + s3LocalPath: ${self:custom.client.distributionFolder}/ + +resources: + Resources: + ## Specifying the S3 Bucket + WebAppS3Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: ${self:custom.s3BucketName} + AccessControl: PublicRead + WebsiteConfiguration: + IndexDocument: index.html + ErrorDocument: index.html + # VersioningConfiguration: + # Status: Enabled + + ## Specifying the policies to make sure all files inside the Bucket are avaialble to CloudFront + WebAppS3BucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: WebAppS3Bucket + PolicyDocument: + Statement: + - Sid: 'AllowCloudFrontAccessIdentity' + Effect: Allow + Action: s3:GetObject + Resource: arn:aws:s3:::${self:custom.s3BucketName}/* + Principal: + AWS: + Fn::Join: + - ' ' + - - 'arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity' + - !Ref OriginAccessIdentity + + OriginAccessIdentity: + Type: AWS::CloudFront::CloudFrontOriginAccessIdentity + Properties: + CloudFrontOriginAccessIdentityConfig: + Comment: Access identity between CloudFront and S3 bucket + + ## Specifying the CloudFront Distribution to server your Web Application + WebAppCloudFrontDistribution: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Origins: + - DomainName: ${self:custom.s3BucketName}.s3.amazonaws.com + ## An identifier for the origin which must be unique within the distribution + Id: myS3Origin + ## In case you don't want to restrict the bucket access use CustomOriginConfig and remove S3OriginConfig + S3OriginConfig: + OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${OriginAccessIdentity} + # CustomOriginConfig: + # HTTPPort: 80 + # HTTPSPort: 443 + # OriginProtocolPolicy: https-only + Enabled: true + IPV6Enabled: true + HttpVersion: http2 + ## Uncomment the following section in case you are using a custom domain + # Aliases: + # - mysite.example.com + DefaultRootObject: index.html + ## Since the Single Page App is taking care of the routing we need to make sure ever path is served with index.html + ## The only exception are files that actually exist e.h. app.js, reset.css + CustomErrorResponses: + - ErrorCode: 404 + ResponseCode: 200 + ResponsePagePath: /index.html + DefaultCacheBehavior: + AllowedMethods: [ 'GET', 'HEAD', 'OPTIONS' ] + CachedMethods: [ 'GET', 'HEAD', 'OPTIONS' ] + ForwardedValues: + Headers: + - Access-Control-Request-Headers + - Access-Control-Request-Method + - Origin + - Authorization + ## Defining if and how the QueryString and Cookies are forwarded to the origin which in this case is S3 + QueryString: false + Cookies: + Forward: none + ## The origin id defined above + TargetOriginId: myS3Origin + ## The protocol that users can use to access the files in the origin. To allow HTTP use `allow-all` + ViewerProtocolPolicy: redirect-to-https + Compress: true + DefaultTTL: 0 + ## The certificate to use when viewers use HTTPS to request objects. + ViewerCertificate: + CloudFrontDefaultCertificate: 'true' + ## Uncomment the following section in case you want to enable logging for CloudFront requests + # Logging: + # IncludeCookies: 'false' + # Bucket: mylogs.s3.amazonaws.com + # Prefix: myprefix + + ## In order to print out the hosted domain via `serverless info` we need to define the DomainName output for CloudFormation + Outputs: + WebAppS3BucketOutput: + Value: !Ref WebAppS3Bucket + WebAppCloudFrontDistributionOutput: + Value: !GetAtt WebAppCloudFrontDistribution.DomainName \ No newline at end of file From 50d2a7ad237794818e44886c5dbe15a8f22119c8 Mon Sep 17 00:00:00 2001 From: Ilya Valasiuk Date: Wed, 17 Mar 2021 18:24:36 +0300 Subject: [PATCH 2/2] add comments about custom plugins --- serverless.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/serverless.yml b/serverless.yml index a56f10f5f..dbace95d6 100644 --- a/serverless.yml +++ b/serverless.yml @@ -10,6 +10,9 @@ provider: plugins: - serverless-finch + # 'serverless-single-page-app-plugin' is a custom plugin that located .serverless_plugins folder. + # Existing plugin (https://www.npmjs.com/package/serverless-single-page-app-plugin) doesn't have invalidate cache feature that often used during CI/CD events. + # How to build your own plugins: https://www.serverless.com/framework/docs/providers/aws/guide/plugins#service-local-plugin - serverless-single-page-app-plugin custom: