forked from pulumi/examples
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Port aws-ts-static-website example to Python (pulumi#573)
* Port aws-ts-static-website example to Python * Fix the TypeScript example README regarding `pulumi refresh`
- Loading branch information
Showing
7 changed files
with
379 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}/') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
pulumi>=1.0.0 | ||
pulumi-aws>=1.0.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.