├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── cloudformation ├── README-SAR.md └── template.yml ├── example ├── Makefile ├── template.yml └── web-site │ ├── index.html │ ├── main.js │ └── style.css └── src └── deployer.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | .idea 4 | output.yml 5 | build 6 | output.txt 7 | setup.cfg -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2020 Aleksandar Simovic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | © 2020 GitHub, Inc. 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -rf build 3 | 4 | build/python: 5 | mkdir -p build/python 6 | 7 | build/python/deployer.py: src/deployer.py build/python 8 | cp $< $@ 9 | 10 | build/python/requests: build/python 11 | pip install requests --target build/python 12 | 13 | output.yml: cloudformation/template.yml build/python/requests build/python/deployer.py 14 | aws cloudformation package --template-file $< --output-template-file $@ --s3-bucket $(DEPLOYMENT_BUCKET_NAME) 15 | 16 | deploy: output.yml 17 | aws cloudformation deploy --template-file $< --stack-name $(STACK_NAME) --capabilities CAPABILITY_IAM 18 | aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query Stacks[].Outputs[].OutputValue --output text 19 | 20 | publish: output.yml 21 | sam publish -t output.yml 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFormation Deploy to S3 2 | 3 | Deploy and publish Frontend SPA apps, UI components, static websites and MicroFrontends to S3 and Serverless Application Repository using this Lambda Layer component. 4 | 5 | For more information, see [How to use CloudFormation to deploy Frontend Apps to S3 and Serverless Application Repository](https://serverless.pub/deploy-frontend-to-s3-and-sar/). 6 | 7 | ## Deploying the example 8 | 9 | There is a full example of a website to be deployed to S3 in the `example` directory, including applying substitutions to files. 10 | 11 | To deploy it, in the `example` directory, run: 12 | 13 | `make deploy STACK_NAME= DEPLOYMENT_BUCKET_NAME=` 14 | 15 | ## Usage instructions 16 | 17 | The easiest place to deploy this is from the [Serverless App Repository](https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:375983427419:applications~deploy-to-s3) 18 | 19 | ```yml 20 | DeploymentLayer: 21 | Type: AWS::Serverless::Application 22 | Properties: 23 | Location: 24 | ApplicationId: arn:aws:serverlessrepo:us-east-1:375983427419:applications/deploy-to-s3 25 | SemanticVersion: 2.4.2 26 | 27 | SiteSource: 28 | Type: AWS::Serverless::Function 29 | Properties: 30 | Layers: 31 | - !GetAtt DeploymentLayer.Outputs.Arn 32 | CodeUri: web-site/ 33 | AutoPublishAlias: live 34 | Runtime: python3.6 35 | Handler: deployer.resource_handler 36 | Timeout: 600 37 | Policies: 38 | - S3FullAccessPolicy: 39 | BucketName: !Ref TargetBucket 40 | DeploymentResource: 41 | Type: AWS::CloudFormation::CustomResource 42 | Properties: 43 | ServiceToken: !GetAtt SiteSource.Arn 44 | Version: !Ref "SiteSource.Version" 45 | TargetBucket: !Ref TargetBucket 46 | 47 | Substitutions: 48 | FilePattern: "*.html" 49 | Values: 50 | APP_NAME: 'Example Application' 51 | STACK_ID: !Ref AWS::StackId 52 | Acl: 'public-read' 53 | CacheControlMaxAge: 600 54 | ``` 55 | 56 | For full instructions and *code comments* please take a look at the example [template.yml](example/template.yml) 57 | 58 | ## Deployment from the source 59 | 60 | For deploying your SPA app, along with your other serverless services, to try it out, in the `/example` directory, run: 61 | 62 | `make deploy STACK_NAME= DEPLOYMENT_BUCKET_NAME=` 63 | -------------------------------------------------------------------------------- /cloudformation/README-SAR.md: -------------------------------------------------------------------------------- 1 | # CloudFormation Deploy to S3 2 | 3 | Deploy and publish Frontend SPA apps, UI components, static websites and MicroFrontends to S3 and Serverless Application Repository using this Lambda Layer component. 4 | 5 | For more information, see [How to use CloudFormation to deploy Frontend Apps to S3 and Serverless Application Repository](https://serverless.pub/deploy-frontend-to-s3-and-sar/). 6 | -------------------------------------------------------------------------------- /cloudformation/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | 4 | Description: S3 Deployment Layer 5 | Resources: 6 | S3DeploymentLayer: 7 | Type: AWS::Serverless::LayerVersion 8 | Properties: 9 | CompatibleRuntimes: 10 | - python3.6 11 | - python3.7 12 | Description: S3 Deployment Layer 13 | LayerName: !Ref AWS::StackName 14 | LicenseInfo: MIT 15 | ContentUri: ../build 16 | RetentionPolicy: Retain 17 | 18 | Outputs: 19 | Arn: 20 | Value: !Ref S3DeploymentLayer 21 | 22 | Metadata: 23 | AWS::ServerlessRepo::Application: 24 | Name: deploy-to-s3 25 | Description: > 26 | Deploy and publish Frontend SPA apps, UI components, static websites and MicroFrontends to S3 27 | and Serverless Application Repository using this Lambda Layer component 28 | Author: Aleksandar Simovic 29 | SpdxLicenseId: MIT 30 | LicenseUrl: ../LICENSE.txt 31 | ReadmeUrl: README-SAR.md 32 | Labels: ['deploy', 's3', 'lambda', 'layer', 'frontend'] 33 | HomePageUrl: https://serverless.pub/deploy-frontend-to-s3-and-sar/ 34 | SemanticVersion: 2.4.2 35 | SourceCodeUrl: https://github.com/serverlesspub/cloudformation-deploy-to-s3 36 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | STACK_NAME ?= s3-deployment-example 2 | 3 | clean: 4 | rm output.yml 5 | 6 | output.yml: template.yml web-site/* 7 | aws cloudformation package --template-file $< --output-template-file $@ --s3-bucket=$(DEPLOYMENT_BUCKET_NAME) 8 | 9 | deploy: output.yml 10 | aws cloudformation deploy --template-file $< --stack-name $(STACK_NAME) --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND 11 | aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query Stacks[].Outputs[].OutputValue --output text 12 | -------------------------------------------------------------------------------- /example/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | Description: Frontend Deploy Example 4 | 5 | Resources: 6 | TargetBucket: 7 | Type: AWS::S3::Bucket 8 | Properties: 9 | WebsiteConfiguration: 10 | IndexDocument: index.html 11 | 12 | DeploymentLayer: 13 | Type: AWS::Serverless::Application 14 | Properties: 15 | Location: 16 | ApplicationId: arn:aws:serverlessrepo:us-east-1:375983427419:applications/deploy-to-s3 17 | SemanticVersion: 2.4.2 18 | 19 | 20 | # this function is used only during deployment, 21 | # we use the web site assets as the source of the function 22 | # tricking cloudformation to pack up the web site files 23 | # using the standard cloudformation package process 24 | SiteSource: 25 | Type: AWS::Serverless::Function 26 | Properties: 27 | Layers: 28 | # the layer contains the deployment code 29 | # so the function "source" can just contain the web assets 30 | - !GetAtt DeploymentLayer.Outputs.Arn 31 | 32 | # point to directory with the assets so cloudformation can 33 | # package and upload them 34 | CodeUri: web-site/ 35 | 36 | # really important: this will ensure that any change in 37 | # the bundled files gets deployed again. we're abusing 38 | # the custom resource pipeline here, so this will be used 39 | # to change parameters of the resource and re-trigger it 40 | AutoPublishAlias: live 41 | 42 | # the following two lines are required to make the layer work 43 | Runtime: python3.6 44 | Handler: deployer.resource_handler 45 | 46 | # set the timeout to something reasonable depending on 47 | # how long it takes to upload your assets to S3 48 | Timeout: 600 49 | 50 | # give the function access to the bucket where it 51 | # will upload the assets 52 | Policies: 53 | - S3FullAccessPolicy: 54 | BucketName: !Ref TargetBucket 55 | 56 | # This is a custom resource that 57 | # will trigger the function during deployment 58 | DeploymentResource: 59 | Type: AWS::CloudFormation::CustomResource 60 | Properties: 61 | 62 | # the following two lines are required to 63 | # ensure that cloudformation will trigger the 64 | # resource every time you change the bundled files 65 | ServiceToken: !GetAtt SiteSource.Arn 66 | Version: !Ref "SiteSource.Version" 67 | 68 | # tell the deployer where to upload the files 69 | TargetBucket: !Ref TargetBucket 70 | 71 | Substitutions: 72 | FilePattern: "*.html" 73 | Values: 74 | APP_NAME: 'Example Application' 75 | STACK_ID: !Ref AWS::StackId 76 | 77 | # Choose the ACL and caching policies 78 | # eg, for directly accessible web site 79 | # use public-read and 10 minutes caching 80 | Acl: 'public-read' 81 | CacheControlMaxAge: 600 82 | 83 | 84 | Outputs: 85 | DestinationBucket: 86 | Value: !Ref TargetBucket 87 | DestinationUrl: 88 | Value: !GetAtt TargetBucket.WebsiteURL 89 | -------------------------------------------------------------------------------- /example/web-site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${APP_NAME} 4 | 5 | 6 |

This is your website that is deployable and publishable on AWS Serverless Application Repository

7 |

Your Stack ID is ${STACK_ID}

8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/web-site/main.js: -------------------------------------------------------------------------------- 1 | function testFunction(){ 2 | console.log('hello') 3 | } 4 | -------------------------------------------------------------------------------- /example/web-site/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } -------------------------------------------------------------------------------- /src/deployer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import boto3 3 | import mimetypes 4 | import json 5 | import requests 6 | import subprocess 7 | import tempfile 8 | import pathlib 9 | import shutil 10 | 11 | s3 = boto3.resource('s3') 12 | defaultContentType = 'application/octet-stream' 13 | 14 | def resource_handler(event, context): 15 | print(event) 16 | try: 17 | target_bucket = event['ResourceProperties']['TargetBucket'] 18 | lambda_src = os.getcwd() 19 | acl = event['ResourceProperties']['Acl'] 20 | cacheControl = 'max-age=' + \ 21 | event['ResourceProperties']['CacheControlMaxAge'] 22 | print(event['RequestType']) 23 | if event['RequestType'] == 'Create' or event['RequestType'] == 'Update': 24 | 25 | if 'Substitutions' in event['ResourceProperties'].keys(): 26 | temp_dir = os.path.join(tempfile.mkdtemp(), context.aws_request_id) 27 | apply_substitutions(event['ResourceProperties']['Substitutions'], temp_dir) 28 | lambda_src = temp_dir 29 | 30 | print('uploading') 31 | upload(lambda_src, target_bucket, acl, cacheControl) 32 | elif event['RequestType'] == 'Delete': 33 | delete(lambda_src, target_bucket, s3) 34 | else: 35 | print('ignoring') 36 | 37 | if not lambda_src == os.getcwd(): 38 | print('removing temporary', lambda_src) 39 | shutil.rmtree(lambda_src) 40 | send_result(event) 41 | 42 | except Exception as err: 43 | send_error(event, err) 44 | return event 45 | 46 | 47 | def upload(lambda_src, target_bucket, acl, cacheControl): 48 | for folder, subs, files in os.walk(lambda_src): 49 | for filename in files: 50 | source_file_path = os.path.join(folder, filename) 51 | destination_s3_key = os.path.relpath(source_file_path, lambda_src) 52 | contentType, encoding = mimetypes.guess_type(source_file_path) 53 | upload_file(source_file_path, target_bucket, 54 | destination_s3_key, s3, acl, cacheControl, contentType) 55 | 56 | 57 | def upload_file(source, bucket, key, s3lib, acl, cacheControl, contentType): 58 | print('uploading from {} {} {}'.format(source, bucket, key)) 59 | contentType = contentType or defaultContentType 60 | s3lib.Object(bucket, key).put(ACL=acl, Body=open(source, 'rb'), 61 | CacheControl=cacheControl, ContentType=contentType) 62 | 63 | def delete(lambda_src, target_bucket, s3lib): 64 | for folder, subs, files in os.walk(lambda_src): 65 | for filename in files: 66 | source_file_path = os.path.join(folder, filename) 67 | destination_s3_key = os.path.relpath(source_file_path, lambda_src) 68 | print('deleting file {} from {}'.format(destination_s3_key, target_bucket)) 69 | s3lib.Object(target_bucket, destination_s3_key).delete() 70 | 71 | def send_result(event): 72 | response_body = json.dumps({ 73 | 'Status': 'SUCCESS', 74 | 'PhysicalResourceId': get_physical_resource_id(event), 75 | 'StackId': event['StackId'], 76 | 'RequestId': event['RequestId'], 77 | 'LogicalResourceId': event['LogicalResourceId'] 78 | }) 79 | print(response_body) 80 | requests.put(event['ResponseURL'], data=response_body) 81 | 82 | 83 | def send_error(event, error): 84 | response_body = json.dumps({ 85 | 'Status': 'FAILED', 86 | 'Reason': str(error), 87 | 'PhysicalResourceId': get_physical_resource_id(event), 88 | 'StackId': event['StackId'], 89 | 'RequestId': event['RequestId'], 90 | 'LogicalResourceId': event['LogicalResourceId'] 91 | }) 92 | print(response_body) 93 | requests.put(event['ResponseURL'], data=response_body) 94 | 95 | def get_physical_resource_id(event): 96 | if 'PhysicalResourceId' in event.keys(): 97 | return event['PhysicalResourceId'] 98 | else: 99 | return event['RequestId'] 100 | 101 | def apply_substitutions(substitutions, temp_dir): 102 | if not 'Values' in substitutions.keys(): 103 | raise ValueError('Substitutions must contain Values') 104 | 105 | if not isinstance(substitutions['Values'], dict): 106 | raise ValueError('Substitutions.Values must be an Object') 107 | 108 | if len(substitutions['Values']) < 1: 109 | raise ValueError('Substitutions.Values must not be empty') 110 | 111 | if not 'FilePattern' in substitutions.keys(): 112 | raise ValueError('Substitutions must contain FilePattern') 113 | 114 | if not isinstance(substitutions['FilePattern'], str): 115 | raise ValueError('Substitutions.FilePattern must be a String') 116 | 117 | if len(substitutions['FilePattern']) < 1: 118 | raise ValueError('Substitutions.FilePattern must not be empty') 119 | 120 | values = substitutions['Values'] 121 | file_pattern = substitutions['FilePattern'] 122 | 123 | subprocess.run(['cp', '-r', os.getcwd(), temp_dir]) 124 | 125 | for full_path in pathlib.Path(temp_dir).glob(file_pattern): 126 | replace_with_command = lambda key: 's/\\${%s}/%s/g'% (sed_escape(key), sed_escape(values[key])) 127 | replacements = list(map(replace_with_command, values.keys())) 128 | sed_script = ';'.join(replacements) 129 | subprocess.run(['sed', sed_script, '-i', str(full_path)], cwd=tempfile.gettempdir(), check=True) 130 | 131 | def sed_escape(text): 132 | return text.replace('/', '\\/') 133 | --------------------------------------------------------------------------------