Skip to content

Commit

Permalink
Port aws-ts-static-website example to Python (pulumi#573)
Browse files Browse the repository at this point in the history
* Port aws-ts-static-website example to Python
* Fix the TypeScript example README regarding `pulumi refresh`
  • Loading branch information
leezen authored Feb 22, 2020
1 parent 1950078 commit 2b1d330
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 3 deletions.
14 changes: 14 additions & 0 deletions aws-py-static-website/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: static-website
description: Static website example
runtime: python
template:
config:
aws:region:
description: The AWS region to deploy into
default: us-west-2
static-website:targetDomain:
description: The domain to serve the website at (e.g. www.example.com)
static-website:pathToWebsiteContents:
description: Relative path to the website's contents (e.g. the `./www` folder)
static-website:certificateArn:
description: (Optional) ACM certificate ARN for the target domain; must be in the us-east-1 region. If omitted, a certificate will be created.
137 changes: 137 additions & 0 deletions aws-py-static-website/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
[![Deploy](https://get.pulumi.com/new/button.svg)](https://app.pulumi.com/new)

# Secure Static Website Using Amazon S3, CloudFront, Route53, and Certificate Manager

This example serves a static website using TypeScript and AWS.

This sample uses the following AWS products:

- [Amazon S3](https://aws.amazon.com/s3/) is used to store the website's contents.
- [Amazon CloudFront](https://aws.amazon.com/cloudfront/) is the CDN serving content.
- [Amazon Route53](https://aws.amazon.com/route53/) is used to set up the DNS for the website.
- [Amazon Certificate Manager](https://aws.amazon.com/certificate-manager/) is used for securing things via HTTPS.

## Getting Started

Configure the Pulumi program. There are several configuration settings that need to be
set:

- `targetDomain` - The domain to serve the website at (e.g. www.example.com). It is assumed that
the parent domain (example.com) is a Route53 Hosted Zone in the AWS account you are running the
Pulumi program in.
- `pathToWebsiteContents` - Directory of the website's contents. e.g. the `./www` folder.

## Deploying and running the program

Note: some values in this example will be different from run to run. These values are indicated
with `***`.

1. Create a new stack:

```bash
$ pulumi stack init website-testing
```

1. Set the AWS region:

```bash
$ pulumi config set aws:region us-west-2
```

1. Create a Python virtualenv, activate it, and install dependencies:

```bash
$ virtualenv -p python3 venv
$ source venv/bin/activate
$ pip3 install -r requirements.txt
```

1. Run `pulumi up` to preview and deploy changes. After the preview is shown you will be
prompted if you want to continue or not.

```bash
$ pulumi up
Previewing update (example):
Type Name Plan
+ pulumi:pulumi:Stack static-website-example create
+ ├─ pulumi:providers:aws east create
+ ├─ aws:s3:Bucket requestLogs create
+ ├─ aws:s3:Bucket contentBucket create
+ │ ├─ aws:s3:BucketObject 404.html create
+ │ └─ aws:s3:BucketObject index.html create
+ ├─ aws:acm:Certificate certificate create
+ ├─ aws:route53:Record ***-validation create
+ ├─ aws:acm:CertificateValidation certificateValidation create
+ ├─ aws:cloudfront:Distribution cdn create
+ └─ aws:route53:Record *** create
```

1. To see the resources that were created, run `pulumi stack output`:

```bash
$ pulumi stack output
Current stack outputs (4):
OUTPUT VALUE
cloudfront_domain ***.cloudfront.net
content_bucket_url s3://***
content_bucket_website_endpoint ***.s3-website-us-west-2.amazonaws.com
target_domain_endpoint https://***/
```

1. To see that the S3 objects exist, you can either use the AWS Console or the AWS CLI:

```bash
$ aws s3 ls $(pulumi stack output content_bucket_url)
2020-02-21 16:58:48 262 404.html
2020-02-21 16:58:48 394 index.html
```

1. Open a browser to the target domain endpoint from above to see your beautiful static website. (Since we don't wait for the CloudFront distribution to completely sync, you may have to wait a few minutes)
1. To clean up resources, run `pulumi destroy` and answer the confirmation question at the prompt.
## Troubleshooting
### Scary HTTPS Warning
When you create an S3 bucket and CloudFront distribution shortly after one another, you'll see
what looks to be HTTPS configuration issues. This has to do with the replication delay between
S3, CloudFront, and the world-wide DNS system.

Just wait 15 minutes or so, and the error will go away. Be sure to refresh in an incognito
window, which will avoid any local caches your browser might have.

### "PreconditionFailed: The request failed because it didn't meet the preconditions"

Sometimes updating the CloudFront distribution will fail with:

```text
"PreconditionFailed: The request failed because it didn't meet the preconditions in one or more
request-header fields."
```

This is caused by CloudFront confirming the ETag of the resource before applying any updates.
ETag is essentially a "version", and AWS is rejecting any requests that are trying to update
any version but the "latest".

This error will occurr when the state of the ETag get out of sync between the Pulumi Service
and AWS. (Which can happen when inspecting the CloudFront distribution in the AWS console.)

You can fix this by running `pulumi refresh` to pickup the newer ETag values.

## Deployment Speed

This example creates a `aws.S3.BucketObject` for every file served from the website. When deploying
large websites, that can lead to very long updates as every individual file is checked for any
changes.

It may be more efficient to not manage individual files using Pulumi and and instead just use the
AWS CLI to sync local files with the S3 bucket directly.

Remove the call to `crawlDirectory` and run `pulumi up`. Pulumi will then delete the contents
of the S3 bucket, and no longer manage their contents. Then do a bulk upload outside of Pulumi
using the AWS CLI.

```bash
aws s3 sync ./www/ s3://example-bucket/
```
196 changes: 196 additions & 0 deletions aws-py-static-website/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import json
import mimetypes
import os

from pulumi import export, FileAsset, ResourceOptions, Config, Output
import pulumi_aws
import pulumi_aws.acm
import pulumi_aws.cloudfront
import pulumi_aws.config
import pulumi_aws.route53
import pulumi_aws.s3

def get_domain_and_subdomain(domain):
"""
Returns the subdomain and the parent domain.
"""

parts = domain.split('.')
if len(parts) < 2:
raise Exception(f'No TLD found on ${domain}')
if len(parts) == 2:
return '', domain
subdomain = parts[0]
parts.pop(0)
return subdomain, '.'.join(parts) + '.'

# Read the configuration for this stack.
stack_config = Config()
target_domain = stack_config.require('targetDomain')
path_to_website_contents = stack_config.require('pathToWebsiteContents')
certificate_arn = stack_config.get('certificateArn')

# Create an S3 bucket configured as a website bucket.
content_bucket = pulumi_aws.s3.Bucket('contentBucket',
bucket=target_domain,
acl='public-read',
website={
'index_document': 'index.html',
'error_document': '404.html'
})

def crawl_directory(content_dir, f):
"""
Crawl `content_dir` (including subdirectories) and apply the function `f` to each file.
"""
for file in os.listdir(content_dir):
filepath = os.path.join(content_dir, file)

if os.path.isdir(filepath):
crawl_directory(filepath, f)
elif os.path.isfile(filepath):
f(filepath)

web_contents_root_path = os.path.join(os.getcwd(), path_to_website_contents)
def bucket_object_converter(filepath):
"""
Takes a file path and returns an bucket object managed by Pulumi
"""
relative_path = filepath.replace(web_contents_root_path + '/', '')
# Determine the mimetype using the `mimetypes` module.
mime_type, _ = mimetypes.guess_type(filepath)
content_file = pulumi_aws.s3.BucketObject(
relative_path,
key=relative_path,
acl='public-read',
bucket=content_bucket,
content_type=mime_type,
source=FileAsset(filepath),
opts=ResourceOptions(parent=content_bucket)
)

# Crawl the web content root path and convert the file paths to S3 object resources.
crawl_directory(web_contents_root_path, bucket_object_converter)

TEN_MINUTES = 60 * 10

# Provision a certificate if the arn is not provided via configuration.
if certificate_arn is None:
# CloudFront is in us-east-1 and expects the ACM certificate to also be in us-east-1.
# So, we create an east_region provider specifically for these operations.
east_region = pulumi_aws.Provider('east', profile=pulumi_aws.config.profile, region='us-east-1')

# Get a certificate for our website domain name.
certificate = pulumi_aws.acm.Certificate('certificate',
domain_name=target_domain, validation_method='DNS', opts=ResourceOptions(provider=east_region))

# Find the Route 53 hosted zone so we can create the validation record.
subdomain, parent_domain = get_domain_and_subdomain(target_domain)
hzid = pulumi_aws.route53.get_zone(name=parent_domain).id

# Create a validation record to prove that we own the domain.
cert_validation_domain = pulumi_aws.route53.Record(f'{target_domain}-validation',
name=certificate.domain_validation_options.apply(
lambda o: o[0]['resourceRecordName']),
zone_id=hzid,
type=certificate.domain_validation_options.apply(
lambda o: o[0]['resourceRecordType']),
records=[certificate.domain_validation_options.apply(
lambda o: o[0]['resourceRecordValue'])],
ttl=TEN_MINUTES)

# Create a special resource to await complete validation of the cert.
# Note that this is not a real AWS resource.
cert_validation = pulumi_aws.acm.CertificateValidation('certificateValidation',
certificate_arn=certificate.arn,
validation_record_fqdns=[cert_validation_domain.fqdn],
opts=ResourceOptions(provider=east_region))

certificate_arn = cert_validation.certificate_arn

# Create a logs bucket for the CloudFront logs
logs_bucket = pulumi_aws.s3.Bucket('requestLogs', bucket=f'{target_domain}-logs', acl='private')

# Create the CloudFront distribution
cdn = pulumi_aws.cloudfront.Distribution('cdn',
enabled=True,
aliases=[
target_domain
],
origins=[{
'originId': content_bucket.arn,
'domain_name': content_bucket.website_endpoint,
'customOriginConfig': {
'originProtocolPolicy': 'http-only',
'httpPort': 80,
'httpsPort': 443,
'originSslProtocols': ['TLSv1.2'],
}
}],
default_root_object='index.html',
default_cache_behavior={
'targetOriginId': content_bucket.arn,
'viewerProtocolPolicy': 'redirect-to-https',
'allowedMethods': ['GET', 'HEAD', 'OPTIONS'],
'cachedMethods': ['GET', 'HEAD', 'OPTIONS'],
'forwardedValues': {
'cookies': { 'forward': 'none' },
'queryString': False,
},
'minTtl': 0,
'defaultTtl': TEN_MINUTES,
'maxTtl': TEN_MINUTES,
},
# PriceClass_100 is the lowest cost tier (US/EU only).
price_class= 'PriceClass_100',
custom_error_responses=[{
'errorCode': 404,
'responseCode': 404,
'responsePagePath': '/404.html'
}],
# Use the certificate we generated for this distribution.
viewer_certificate={
'acmCertificateArn': certificate_arn,
'sslSupportMethod': 'sni-only',
},
restrictions={
'geoRestriction': {
'restrictionType': 'none'
}
},
# Put access logs in the log bucket we created earlier.
logging_config={
'bucket': logs_bucket.bucket_domain_name,
'includeCookies': False,
'prefix': f'${target_domain}/',
},
# CloudFront typically takes 15 minutes to fully deploy a new distribution.
# Skip waiting for that to complete.
wait_for_deployment=False)

def create_alias_record(target_domain, distribution):
"""
Create a Route 53 Alias A record from the target domain name to the CloudFront distribution.
"""
subdomain, parent_domain = get_domain_and_subdomain(target_domain)
hzid = pulumi_aws.route53.get_zone(name=parent_domain).id
return pulumi_aws.route53.Record(target_domain,
name=subdomain,
zone_id=hzid,
type='A',
aliases=[
{
'name': distribution.domain_name,
'zoneId': distribution.hosted_zone_id,
'evaluateTargetHealth': True
}
]
)

alias_a_record = create_alias_record(target_domain, cdn)

# Export the bucket URL, bucket website endpoint, and the CloudFront distribution information.
export('content_bucket_url', Output.concat('s3://', content_bucket.bucket))
export('content_bucket_website_endpoint', content_bucket.website_endpoint)
export('cloudfront_domain', cdn.domain_name)
export('target_domain_endpoint', f'https://{target_domain}/')
2 changes: 2 additions & 0 deletions aws-py-static-website/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pulumi>=1.0.0
pulumi-aws>=1.0.0
11 changes: 11 additions & 0 deletions aws-py-static-website/www/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Super-amazing static website! - Page not found</title>
</head>

<body>
<h1>404 - Page not found</h1>
<p>Looks like the page you were looking for wasn't found. But don't give up!</p>
</body>
18 changes: 18 additions & 0 deletions aws-py-static-website/www/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Super-amazing static website!</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<style>
body {
background-color: lightblue;
}
</style>
</head>

<body>
<h1>Hello, world!</h1>
<p>Made with ♥ using <a href="https://pulumi.com">Pulumi</a>.</p>
</body>
Loading

0 comments on commit 2b1d330

Please sign in to comment.