Skip to content

Commit 8b272d2

Browse files
tobiasmcnultycopelcoronardcaktus
authored
Add optional CloudFront distribution for app server (#35)
* add (optional) CloudFront distribution for app server * add IAM policy to allow cloudfront api access from the app servers * add CloudFront app distribution support for the Dokku stack * forward only the Host header to the origin in the CloudFront AppDistribution (forwarding all headers breaks caching) * add missing Ref() * Revert "add CloudFront app distribution support for the Dokku stack" This reverts commit a26c081. * re-enable CloudFront AppDistribution for ELB-enabled Dokku stack * fix ARN + permissions for AppDistributionInvalidationPolicy * enable all HTTP methods for AppDistribution * resource-level IAM permissions aren't supported by CloudFront, so disable them * clean up conditional imports in __init__.py * remove unused imports * allow configuring ViewerProtocolPolicy for app server CloudFront distribution * modernize CloudFront configuration * update changelog * separate cloudfront to its own stack * restore accidentally deleted code * update changelog * use new cache policy method Co-authored-by: Ronard <[email protected]> --------- Co-authored-by: Colin Copeland <[email protected]> Co-authored-by: Ronard <[email protected]>
1 parent a1219b0 commit 8b272d2

File tree

13 files changed

+306
-39
lines changed

13 files changed

+306
-39
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Change Log
44
`X.Y.Z`_ (TBD-DD-DD)
55
---------------------
66

7-
* TBD
7+
* Add support for CloudFront Distribution in front of the application server via a separate CloudFormation stack (must be deployed on its own, after the main stack creation)
88

99

1010
`2.2.0`_ (2024-08-01)

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ templates:
1414
# USE_DOKKU=on USE_NAT_GATEWAY=on python -c 'import stack' > content/dokku-nat.yaml (disabled; need to SSH to instance to deploy)
1515
USE_GOVCLOUD=on python -c 'import stack' > content/gc-no-nat.yaml
1616
USE_GOVCLOUD=on USE_NAT_GATEWAY=on python -c 'import stack' > content/gc-nat.yaml
17+
USE_CLOUDFRONT=on python -c 'import stack' > content/cloudfront.yaml
1718

1819
versioned_templates: templates
1920
# version must be passed via the command-line, e.g., make VERSION=x.y.z versioned_templates

stack/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
USE_EKS = os.environ.get("USE_EKS") == "on"
99
USE_GOVCLOUD = os.environ.get("USE_GOVCLOUD") == "on"
1010
USE_NAT_GATEWAY = os.environ.get("USE_NAT_GATEWAY") == "on"
11+
USE_CLOUDFRONT = os.environ.get("USE_CLOUDFRONT") == "on"
1112

12-
if USE_EKS:
13+
if USE_CLOUDFRONT:
14+
from . import cdn # noqa: F401
15+
elif USE_EKS:
1316
from . import assets # noqa: F401
1417
from . import cache # noqa: F401
1518
from . import database # noqa: F401

stack/assets.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
use_aes256_encryption_cond,
4242
use_cmk_arn
4343
)
44-
from .domain import domain_name, domain_name_alternates, no_alt_domains
44+
from .domain import all_domains_list
4545
from .sftp import use_sftp_condition, use_sftp_with_kms_condition
4646
from .template import template
4747
from .utils import ParameterWithDefaults as Parameter
@@ -71,18 +71,9 @@
7171
DeletionPolicy="Retain",
7272
CorsConfiguration=CorsConfiguration(
7373
CorsRules=[CorsRules(
74-
AllowedOrigins=Split(";", Join("", [
75-
"https://", domain_name,
76-
If(
77-
no_alt_domains,
78-
# if we don't have any alternate domains, return an empty string
79-
"",
80-
# otherwise, return the ';https://' that will be needed by the first domain
81-
";https://",
82-
),
83-
# then, add all the alternate domains, joined together with ';https://'
84-
Join(";https://", domain_name_alternates),
85-
# now that we have a string of origins separated by ';', Split() is used to make it into a list again
74+
AllowedOrigins=Split(';', Join('', [
75+
'https://',
76+
Join(';https://', all_domains_list)
8677
])),
8778
AllowedMethods=[
8879
"POST",
@@ -283,7 +274,7 @@
283274
Parameter(
284275
"AssetsCloudFrontDomain",
285276
Description="A custom domain name (CNAME) for your CloudFront distribution, e.g., "
286-
"\"static.example.com\".",
277+
"\"static.example.com\" (optional).",
287278
Type="String",
288279
Default="",
289280
),

stack/bastion.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,8 @@
1515
)
1616

1717
from . import USE_EKS
18-
from .common import (
19-
cmk_arn,
20-
dont_create_value,
21-
use_aes256_encryption,
22-
use_cmk_arn
23-
)
18+
from .common import cmk_arn, use_aes256_encryption, use_cmk_arn
19+
from .constants import dont_create_value
2420
from .template import template
2521
from .vpc import public_subnet_a, vpc
2622

stack/cache.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717

1818
from .common import (
1919
cmk_arn,
20-
dont_create_value,
2120
use_aes256_encryption,
2221
use_aes256_encryption_cond,
2322
use_cmk_arn
2423
)
24+
from .constants import dont_create_value
2525
from .template import template
2626
from .utils import ParameterWithDefaults as Parameter
2727
from .vpc import (

stack/cdn.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
from troposphere import (
2+
AWS_REGION,
3+
Equals,
4+
GetAtt,
5+
If,
6+
Join,
7+
Not,
8+
Output,
9+
Parameter,
10+
Ref,
11+
iam
12+
)
13+
from troposphere.cloudfront import (
14+
CacheCookiesConfig,
15+
CacheHeadersConfig,
16+
CachePolicy,
17+
CachePolicyConfig,
18+
CacheQueryStringsConfig,
19+
CustomOriginConfig,
20+
DefaultCacheBehavior,
21+
Distribution,
22+
DistributionConfig,
23+
Origin,
24+
ParametersInCacheKeyAndForwardedToOrigin,
25+
ViewerCertificate
26+
)
27+
28+
from .certificates import application as app_certificate
29+
from .domain import all_domains_list
30+
from .template import template
31+
32+
origin_domain_name = Ref(
33+
template.add_parameter(
34+
Parameter(
35+
"AppCloudFrontOriginDomainName",
36+
Description="Domain name of the origin server",
37+
Type="String",
38+
Default="",
39+
),
40+
group="Application Server",
41+
label="CloudFront Origin Domain Name",
42+
)
43+
)
44+
45+
instance_role = Ref(
46+
template.add_parameter(
47+
Parameter(
48+
"AppCloudFrontRoleArn",
49+
Description="ARN of the role to add IAM permissions for invalidating this distribution",
50+
Type="String",
51+
Default="",
52+
),
53+
group="Application Server",
54+
label="CloudFront Role ARN",
55+
)
56+
)
57+
58+
origin_request_policy_id = Ref(
59+
template.add_parameter(
60+
Parameter(
61+
"AppCloudFrontOriginRequestPolicyId",
62+
Description="The unique identifier of the origin request policy to attach to the app cache behavior",
63+
Type="String",
64+
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer
65+
# Recommended for custom origins
66+
Default="216adef6-5c7f-47e4-b989-5492eafa07d3",
67+
),
68+
group="Application Server",
69+
label="Origin Request Policy ID",
70+
)
71+
)
72+
73+
app_protocol_policy = template.add_parameter(
74+
Parameter(
75+
"AppCloudFrontProtocolPolicy",
76+
Description="The protocols allowed by the application server's CloudFront distribution. See: "
77+
"http://docs.aws.amazon.com/cloudfront/latest/APIReference/API_DefaultCacheBehavior.html",
78+
Type="String",
79+
AllowedValues=["redirect-to-https", "https-only", "allow-all"],
80+
Default="redirect-to-https",
81+
),
82+
group="Application Server",
83+
label="CloudFront Protocol Policy",
84+
)
85+
86+
app_forwarded_headers = template.add_parameter(
87+
Parameter(
88+
"AppCloudFrontForwardedHeaders",
89+
Description=(
90+
"The CachePolicy headers that will be forwarded to the origin and used in the cache key. "
91+
"The 'Host' header is required for SSL on an Elastic Load Balancer, but it "
92+
"should NOT be passed to a Lambda Function URL."
93+
),
94+
Type="CommaDelimitedList",
95+
Default="",
96+
),
97+
group="Application Server",
98+
label="CloudFront Forwarded Headers",
99+
)
100+
app_forwarded_headers_condition = "AppCloudFrontForwardedHeadersCondition"
101+
template.add_condition(
102+
app_forwarded_headers_condition,
103+
Not(Equals(Join("", Ref(app_forwarded_headers)), "")),
104+
)
105+
106+
# Currently, you can specify only certificates that are in the US East (N. Virginia) region.
107+
# http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distributionconfig-viewercertificate.html
108+
us_east_1_condition = "UsEast1Condition"
109+
template.add_condition(
110+
us_east_1_condition,
111+
Equals(Ref(AWS_REGION), "us-east-1"),
112+
)
113+
114+
app_certificate_arn = template.add_parameter(
115+
Parameter(
116+
"AppCloudFrontCertArn",
117+
Description="If your stack is NOT in the us-east-1 you must manually create an ACM certificate for "
118+
"your application domain in the us-east-1 region and provide its ARN here.",
119+
Type="String",
120+
),
121+
group="Application Server",
122+
label="CloudFront SSL Certificate ARN",
123+
)
124+
app_certificate_arn_condition = "AppCloudFrontCertArnCondition"
125+
template.add_condition(
126+
app_certificate_arn_condition, Not(Equals(Ref(app_certificate_arn), ""))
127+
)
128+
129+
cache_policy = template.add_resource(
130+
CachePolicy(
131+
"AppCloudFrontCachePolicy",
132+
CachePolicyConfig=CachePolicyConfig(
133+
Name="AppCachePolicy",
134+
DefaultTTL=86400, # 1 day
135+
MaxTTL=31536000, # 1 year
136+
MinTTL=0,
137+
ParametersInCacheKeyAndForwardedToOrigin=ParametersInCacheKeyAndForwardedToOrigin(
138+
CookiesConfig=CacheCookiesConfig(
139+
CookieBehavior="none",
140+
),
141+
EnableAcceptEncodingGzip=True,
142+
EnableAcceptEncodingBrotli=True,
143+
HeadersConfig=If(
144+
app_forwarded_headers_condition,
145+
CacheHeadersConfig(
146+
# Determines whether any HTTP headers are included in the
147+
# cache key and in requests that CloudFront sends to the
148+
# origin
149+
# * whitelist: Only the HTTP headers that are listed in the
150+
# Headers type are included in the cache key and in
151+
# requests that CloudFront sends to the origin.
152+
HeaderBehavior="whitelist",
153+
Headers=Ref(app_forwarded_headers),
154+
),
155+
CacheHeadersConfig(
156+
HeaderBehavior="none",
157+
),
158+
),
159+
QueryStringsConfig=CacheQueryStringsConfig(
160+
# Determines whether any URL query strings in viewer
161+
# requests are included in the cache key and in requests
162+
# that CloudFront sends to the origin
163+
QueryStringBehavior="all",
164+
),
165+
),
166+
),
167+
)
168+
)
169+
170+
# Create a CloudFront CDN distribution
171+
app_distribution = template.add_resource(
172+
Distribution(
173+
"AppCloudFrontDistribution",
174+
DistributionConfig=DistributionConfig(
175+
Aliases=all_domains_list,
176+
HttpVersion="http2",
177+
# If we're in us-east-1, use the application certificate tied to the load balancer, otherwise,
178+
# use the manually-created cert
179+
ViewerCertificate=If(
180+
us_east_1_condition,
181+
ViewerCertificate(
182+
AcmCertificateArn=app_certificate,
183+
SslSupportMethod="sni-only",
184+
# Default/recommended on the AWS console, as of May, 2023
185+
MinimumProtocolVersion="TLSv1.2_2021",
186+
),
187+
If(
188+
app_certificate_arn_condition,
189+
ViewerCertificate(
190+
AcmCertificateArn=Ref(app_certificate_arn),
191+
SslSupportMethod="sni-only",
192+
MinimumProtocolVersion="TLSv1.2_2021",
193+
),
194+
Ref("AWS::NoValue"),
195+
),
196+
),
197+
Origins=[
198+
Origin(
199+
Id="ApplicationServer",
200+
DomainName=origin_domain_name,
201+
CustomOriginConfig=CustomOriginConfig(
202+
OriginProtocolPolicy="https-only",
203+
),
204+
)
205+
],
206+
DefaultCacheBehavior=DefaultCacheBehavior(
207+
TargetOriginId="ApplicationServer",
208+
Compress="true",
209+
AllowedMethods=[
210+
"DELETE",
211+
"GET",
212+
"HEAD",
213+
"OPTIONS",
214+
"PATCH",
215+
"POST",
216+
"PUT",
217+
],
218+
CachePolicyId=Ref(cache_policy),
219+
CachedMethods=["HEAD", "GET"],
220+
OriginRequestPolicyId=origin_request_policy_id,
221+
ViewerProtocolPolicy=Ref(app_protocol_policy),
222+
),
223+
Enabled=True,
224+
),
225+
)
226+
)
227+
228+
invalidation_policy = template.add_resource(
229+
iam.PolicyType(
230+
"AppCloudFrontInvalidationPolicy",
231+
PolicyName="AppCloudFrontInvalidationPolicy",
232+
PolicyDocument=dict(
233+
Statement=[
234+
dict(
235+
Effect="Allow",
236+
Action=[
237+
"cloudfront:GetDistribution",
238+
"cloudfront:GetDistributionConfig",
239+
"cloudfront:ListDistributions",
240+
"cloudfront:ListCloudFrontOriginAccessIdentities",
241+
"cloudfront:CreateInvalidation",
242+
"cloudfront:GetInvalidation",
243+
"cloudfront:ListInvalidations",
244+
],
245+
Resource="*",
246+
# TODO: if/when CloudFront supports resource-level IAM permissions, enable them, e.g.:
247+
# Resource=Join("", [arn_prefix, ":cloudfront:::distribution/", Ref(app_distribution)]),
248+
# See: https://stackoverflow.com/a/29563986/166053
249+
),
250+
],
251+
),
252+
Roles=[instance_role],
253+
)
254+
)
255+
256+
# Output CloudFront url
257+
template.add_output(
258+
Output(
259+
"AppCloudFrontDomainName",
260+
Description="The app CDN domain name",
261+
Value=GetAtt(app_distribution, "DomainName"),
262+
)
263+
)

stack/certificates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from troposphere import Equals, If, Not, Or, Ref
55
from troposphere.certificatemanager import Certificate, DomainValidationOption
66

7-
from .common import dont_create_value
7+
from .constants import dont_create_value
88
from .domain import domain_name, domain_name_alternates, no_alt_domains
99
from .template import template
1010
from .utils import ParameterWithDefaults as Parameter

stack/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
from troposphere import AWS_REGION, Equals, If, Not, Ref
24

35
from . import USE_DOKKU, USE_EB, USE_EC2, USE_ECS, USE_GOVCLOUD
@@ -6,6 +8,12 @@
68

79
dont_create_value = "(none)"
810

11+
# TODO: clean up naming for this role so it's the same for all configurations
12+
if os.environ.get('USE_EB') == 'on':
13+
instance_role = "WebServerRole"
14+
else:
15+
instance_role = "ContainerInstanceRole"
16+
917
in_govcloud_region = "InGovCloudRegion"
1018
template.add_condition(in_govcloud_region, Equals(Ref(AWS_REGION), "us-gov-west-1"))
1119
arn_prefix = If(in_govcloud_region, "arn:aws-us-gov", "arn:aws")

stack/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dont_create_value = "(none)"

0 commit comments

Comments
 (0)