@staticforge/aws 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/clients.d.ts +22 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +64 -0
- package/dist/clients.js.map +1 -0
- package/dist/deployer.d.ts +6 -0
- package/dist/deployer.d.ts.map +1 -0
- package/dist/deployer.js +379 -0
- package/dist/deployer.js.map +1 -0
- package/dist/iac.d.ts +26 -0
- package/dist/iac.d.ts.map +1 -0
- package/dist/iac.js +223 -0
- package/dist/iac.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/routing.d.ts +32 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +111 -0
- package/dist/routing.js.map +1 -0
- package/package.json +59 -3
- package/terraform/modules/site/cdn.tf +191 -0
- package/terraform/modules/site/ci.tf +117 -0
- package/terraform/modules/site/domain.tf +77 -0
- package/terraform/modules/site/logging.tf +82 -0
- package/terraform/modules/site/outputs.tf +69 -0
- package/terraform/modules/site/router.js +136 -0
- package/terraform/modules/site/router.tf +33 -0
- package/terraform/modules/site/storage.tf +107 -0
- package/terraform/modules/site/variables.tf +106 -0
- package/terraform/modules/site/versions.tf +11 -0
- package/terraform/state-bootstrap/main.tf +104 -0
- package/terraform/state-bootstrap/outputs.tf +11 -0
- package/terraform/state-bootstrap/variables.tf +21 -0
- package/terraform/state-bootstrap/versions.tf +10 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
locals {
|
|
2
|
+
custom_domain = var.domain != null
|
|
3
|
+
aliases = local.custom_domain ? concat(
|
|
4
|
+
[var.domain],
|
|
5
|
+
var.preview_domain == null ? [] : ["*.${var.preview_domain}"],
|
|
6
|
+
) : []
|
|
7
|
+
# Served by CloudFront's error responses; sforge copies the active version's
|
|
8
|
+
# 404.html here on every deploy and rollback.
|
|
9
|
+
error_page = "/_sforge/errors/404.html"
|
|
10
|
+
headers = var.security_headers
|
|
11
|
+
header_policies = merge(
|
|
12
|
+
{ default = local.headers.content_security_policy },
|
|
13
|
+
length(var.search_paths) > 0 ? { search = local.headers.search_content_security_policy } : {},
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
resource "aws_cloudfront_origin_access_control" "site" {
|
|
18
|
+
name = substr("sforge-${local.id}", 0, 64)
|
|
19
|
+
description = "staticforge ${local.id}"
|
|
20
|
+
origin_access_control_origin_type = "s3"
|
|
21
|
+
signing_behavior = "always"
|
|
22
|
+
signing_protocol = "sigv4"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Honors the Cache-Control set on each object: immutable for fingerprinted
|
|
26
|
+
# assets, max-age=0 + must-revalidate (ETag revalidation) for everything else.
|
|
27
|
+
resource "aws_cloudfront_cache_policy" "site" {
|
|
28
|
+
name = "sforge-${local.id}"
|
|
29
|
+
comment = "staticforge: origin Cache-Control, no cookies or query strings"
|
|
30
|
+
min_ttl = 0
|
|
31
|
+
default_ttl = 86400
|
|
32
|
+
max_ttl = 31536000
|
|
33
|
+
|
|
34
|
+
parameters_in_cache_key_and_forwarded_to_origin {
|
|
35
|
+
enable_accept_encoding_brotli = true
|
|
36
|
+
enable_accept_encoding_gzip = true
|
|
37
|
+
cookies_config {
|
|
38
|
+
cookie_behavior = "none"
|
|
39
|
+
}
|
|
40
|
+
headers_config {
|
|
41
|
+
header_behavior = "none"
|
|
42
|
+
}
|
|
43
|
+
query_strings_config {
|
|
44
|
+
query_string_behavior = "none"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
resource "aws_cloudfront_response_headers_policy" "site" {
|
|
50
|
+
for_each = local.header_policies
|
|
51
|
+
name = "sforge-${local.id}-${each.key}"
|
|
52
|
+
comment = "staticforge security headers (${each.key})"
|
|
53
|
+
|
|
54
|
+
security_headers_config {
|
|
55
|
+
content_type_options {
|
|
56
|
+
override = true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dynamic "frame_options" {
|
|
60
|
+
for_each = local.headers.frame_options == null ? [] : [local.headers.frame_options]
|
|
61
|
+
content {
|
|
62
|
+
frame_option = frame_options.value
|
|
63
|
+
override = true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
dynamic "referrer_policy" {
|
|
68
|
+
for_each = local.headers.referrer_policy == null ? [] : [local.headers.referrer_policy]
|
|
69
|
+
content {
|
|
70
|
+
referrer_policy = referrer_policy.value
|
|
71
|
+
override = true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
dynamic "strict_transport_security" {
|
|
76
|
+
for_each = local.headers.hsts_max_age == null ? [] : [local.headers.hsts_max_age]
|
|
77
|
+
content {
|
|
78
|
+
access_control_max_age_sec = strict_transport_security.value
|
|
79
|
+
include_subdomains = local.headers.hsts_include_subdomains
|
|
80
|
+
preload = local.headers.hsts_preload
|
|
81
|
+
override = true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
dynamic "content_security_policy" {
|
|
86
|
+
for_each = each.value == null ? [] : [each.value]
|
|
87
|
+
content {
|
|
88
|
+
content_security_policy = content_security_policy.value
|
|
89
|
+
override = true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
dynamic "custom_headers_config" {
|
|
95
|
+
for_each = local.headers.permissions_policy == null ? [] : [local.headers.permissions_policy]
|
|
96
|
+
content {
|
|
97
|
+
items {
|
|
98
|
+
header = "Permissions-Policy"
|
|
99
|
+
value = custom_headers_config.value
|
|
100
|
+
override = true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
resource "aws_cloudfront_distribution" "site" {
|
|
107
|
+
enabled = true
|
|
108
|
+
comment = "staticforge ${local.id}"
|
|
109
|
+
is_ipv6_enabled = true
|
|
110
|
+
http_version = "http2and3"
|
|
111
|
+
price_class = var.price_class
|
|
112
|
+
aliases = local.aliases
|
|
113
|
+
web_acl_id = var.waf_web_acl_arn
|
|
114
|
+
tags = local.tags
|
|
115
|
+
|
|
116
|
+
origin {
|
|
117
|
+
origin_id = "site"
|
|
118
|
+
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
|
119
|
+
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
default_cache_behavior {
|
|
123
|
+
target_origin_id = "site"
|
|
124
|
+
viewer_protocol_policy = "redirect-to-https"
|
|
125
|
+
allowed_methods = ["GET", "HEAD"]
|
|
126
|
+
cached_methods = ["GET", "HEAD"]
|
|
127
|
+
compress = true
|
|
128
|
+
cache_policy_id = aws_cloudfront_cache_policy.site.id
|
|
129
|
+
response_headers_policy_id = aws_cloudfront_response_headers_policy.site["default"].id
|
|
130
|
+
|
|
131
|
+
function_association {
|
|
132
|
+
event_type = "viewer-request"
|
|
133
|
+
function_arn = aws_cloudfront_function.router.arn
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
dynamic "ordered_cache_behavior" {
|
|
138
|
+
for_each = var.search_paths
|
|
139
|
+
content {
|
|
140
|
+
path_pattern = ordered_cache_behavior.value
|
|
141
|
+
target_origin_id = "site"
|
|
142
|
+
viewer_protocol_policy = "redirect-to-https"
|
|
143
|
+
allowed_methods = ["GET", "HEAD"]
|
|
144
|
+
cached_methods = ["GET", "HEAD"]
|
|
145
|
+
compress = true
|
|
146
|
+
cache_policy_id = aws_cloudfront_cache_policy.site.id
|
|
147
|
+
response_headers_policy_id = aws_cloudfront_response_headers_policy.site["search"].id
|
|
148
|
+
|
|
149
|
+
function_association {
|
|
150
|
+
event_type = "viewer-request"
|
|
151
|
+
function_arn = aws_cloudfront_function.router.arn
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# S3 answers 404 for missing objects (403 if listing were not allowed).
|
|
157
|
+
dynamic "custom_error_response" {
|
|
158
|
+
for_each = [403, 404]
|
|
159
|
+
content {
|
|
160
|
+
error_code = custom_error_response.value
|
|
161
|
+
response_code = 404
|
|
162
|
+
response_page_path = local.error_page
|
|
163
|
+
error_caching_min_ttl = 10
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
restrictions {
|
|
168
|
+
geo_restriction {
|
|
169
|
+
restriction_type = "none"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
viewer_certificate {
|
|
174
|
+
cloudfront_default_certificate = !local.custom_domain
|
|
175
|
+
acm_certificate_arn = local.custom_domain ? aws_acm_certificate_validation.site[0].certificate_arn : null
|
|
176
|
+
ssl_support_method = local.custom_domain ? "sni-only" : null
|
|
177
|
+
# The default *.cloudfront.net certificate does not allow choosing the policy.
|
|
178
|
+
minimum_protocol_version = local.custom_domain ? "TLSv1.2_2021" : "TLSv1"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
dynamic "logging_config" {
|
|
182
|
+
for_each = var.logging ? [1] : []
|
|
183
|
+
content {
|
|
184
|
+
bucket = aws_s3_bucket.logs[0].bucket_domain_name
|
|
185
|
+
include_cookies = false
|
|
186
|
+
prefix = "cloudfront/"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
depends_on = [aws_s3_bucket_acl.logs]
|
|
191
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# GitHub Actions deploy role, assumed through OIDC (no long-lived keys). The
|
|
2
|
+
# trust policy accepts only the configured branches, environments and, if
|
|
3
|
+
# enabled, pull_request workflows of one repository.
|
|
4
|
+
|
|
5
|
+
locals {
|
|
6
|
+
ci = var.ci
|
|
7
|
+
ci_create = local.ci != null && try(local.ci.create_oidc_provider, false)
|
|
8
|
+
ci_subjects = local.ci == null ? [] : concat(
|
|
9
|
+
[for branch in local.ci.branches : "repo:${local.ci.repo}:ref:refs/heads/${branch}"],
|
|
10
|
+
[for environment in local.ci.environments : "repo:${local.ci.repo}:environment:${environment}"],
|
|
11
|
+
local.ci.pull_requests ? ["repo:${local.ci.repo}:pull_request"] : [],
|
|
12
|
+
)
|
|
13
|
+
oidc_provider_arn = local.ci == null ? null : (
|
|
14
|
+
local.ci_create ? aws_iam_openid_connect_provider.github[0].arn : local.ci.oidc_provider_arn
|
|
15
|
+
)
|
|
16
|
+
state_bucket = local.ci == null ? "" : (local.ci.state_bucket == null ? "" : local.ci.state_bucket)
|
|
17
|
+
state_key = local.ci == null ? "" : (local.ci.state_key == null ? "" : local.ci.state_key)
|
|
18
|
+
state_object = "arn:aws:s3:::${local.state_bucket}/${local.state_key}"
|
|
19
|
+
# This target's state (and its lock file) only, for `sforge status` and plans in CI.
|
|
20
|
+
state_statements = [for statement in [
|
|
21
|
+
{
|
|
22
|
+
Sid = "ListState"
|
|
23
|
+
Effect = "Allow"
|
|
24
|
+
Action = "s3:ListBucket"
|
|
25
|
+
Resource = "arn:aws:s3:::${local.state_bucket}"
|
|
26
|
+
Condition = { StringLike = { "s3:prefix" = [local.state_key, "${local.state_key}.tflock"] } }
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
Sid = "ReadWriteState"
|
|
30
|
+
Effect = "Allow"
|
|
31
|
+
Action = ["s3:GetObject", "s3:PutObject"]
|
|
32
|
+
Resource = [local.state_object, "${local.state_object}.tflock"]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
Sid = "UnlockState"
|
|
36
|
+
Effect = "Allow"
|
|
37
|
+
Action = "s3:DeleteObject"
|
|
38
|
+
Resource = "${local.state_object}.tflock"
|
|
39
|
+
},
|
|
40
|
+
] : statement if local.state_bucket != ""]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# One per account. sforge reuses an existing provider and creates one (tagged
|
|
44
|
+
# with this site and target) only when the account has none.
|
|
45
|
+
resource "aws_iam_openid_connect_provider" "github" {
|
|
46
|
+
count = local.ci_create ? 1 : 0
|
|
47
|
+
url = "https://token.actions.githubusercontent.com"
|
|
48
|
+
client_id_list = ["sts.amazonaws.com"]
|
|
49
|
+
tags = merge(local.tags, { "sforge:owner" = local.id })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resource "aws_iam_role" "ci" {
|
|
53
|
+
count = local.ci == null ? 0 : 1
|
|
54
|
+
name = substr("sforge-${local.id}-deploy", 0, 64)
|
|
55
|
+
description = "staticforge: GitHub Actions deploys of ${local.id} (${local.ci.repo})"
|
|
56
|
+
max_session_duration = 3600
|
|
57
|
+
tags = local.tags
|
|
58
|
+
|
|
59
|
+
assume_role_policy = jsonencode({
|
|
60
|
+
Version = "2012-10-17"
|
|
61
|
+
Statement = [{
|
|
62
|
+
Effect = "Allow"
|
|
63
|
+
Principal = { Federated = local.oidc_provider_arn }
|
|
64
|
+
Action = "sts:AssumeRoleWithWebIdentity"
|
|
65
|
+
Condition = {
|
|
66
|
+
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
|
|
67
|
+
StringLike = { "token.actions.githubusercontent.com:sub" = local.ci_subjects }
|
|
68
|
+
}
|
|
69
|
+
}]
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resource "aws_iam_role_policy" "ci" {
|
|
74
|
+
count = local.ci == null ? 0 : 1
|
|
75
|
+
name = "deploy"
|
|
76
|
+
role = aws_iam_role.ci[0].id
|
|
77
|
+
|
|
78
|
+
policy = jsonencode({
|
|
79
|
+
Version = "2012-10-17"
|
|
80
|
+
Statement = concat(
|
|
81
|
+
[
|
|
82
|
+
{
|
|
83
|
+
Sid = "ListContent"
|
|
84
|
+
Effect = "Allow"
|
|
85
|
+
Action = "s3:ListBucket"
|
|
86
|
+
Resource = aws_s3_bucket.site.arn
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
Sid = "WriteContent"
|
|
90
|
+
Effect = "Allow"
|
|
91
|
+
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
|
|
92
|
+
Resource = "${aws_s3_bucket.site.arn}/*"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
Sid = "SwitchVersions"
|
|
96
|
+
Effect = "Allow"
|
|
97
|
+
Action = [
|
|
98
|
+
"cloudfront-keyvaluestore:DescribeKeyValueStore",
|
|
99
|
+
"cloudfront-keyvaluestore:GetKey",
|
|
100
|
+
"cloudfront-keyvaluestore:ListKeys",
|
|
101
|
+
"cloudfront-keyvaluestore:PutKey",
|
|
102
|
+
"cloudfront-keyvaluestore:DeleteKey",
|
|
103
|
+
"cloudfront-keyvaluestore:UpdateKeys",
|
|
104
|
+
]
|
|
105
|
+
Resource = aws_cloudfront_key_value_store.site.arn
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
Sid = "Invalidate"
|
|
109
|
+
Effect = "Allow"
|
|
110
|
+
Action = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation", "cloudfront:GetDistribution"]
|
|
111
|
+
Resource = aws_cloudfront_distribution.site.arn
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
local.state_statements,
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Optional custom domain: an ACM certificate in us-east-1 (required by
|
|
2
|
+
# CloudFront), validated through Route 53 when hosted_zone is set. Without a
|
|
3
|
+
# hosted zone, sforge creates the certificate first, prints the validation
|
|
4
|
+
# records and waits until they resolve.
|
|
5
|
+
|
|
6
|
+
resource "aws_acm_certificate" "site" {
|
|
7
|
+
count = local.custom_domain ? 1 : 0
|
|
8
|
+
provider = aws.us_east_1
|
|
9
|
+
domain_name = var.domain
|
|
10
|
+
subject_alternative_names = var.preview_domain == null ? [] : ["*.${var.preview_domain}"]
|
|
11
|
+
validation_method = "DNS"
|
|
12
|
+
tags = local.tags
|
|
13
|
+
|
|
14
|
+
lifecycle {
|
|
15
|
+
create_before_destroy = true
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
data "aws_route53_zone" "site" {
|
|
20
|
+
count = local.custom_domain && var.hosted_zone != null ? 1 : 0
|
|
21
|
+
name = var.hosted_zone
|
|
22
|
+
private_zone = false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
locals {
|
|
26
|
+
# Keyed by the configured names, so the keys are known when planning.
|
|
27
|
+
certificate_names = local.custom_domain ? toset(concat(
|
|
28
|
+
[var.domain],
|
|
29
|
+
var.preview_domain == null ? [] : ["*.${var.preview_domain}"],
|
|
30
|
+
)) : toset([])
|
|
31
|
+
validation_options = local.custom_domain ? {
|
|
32
|
+
for name in local.certificate_names : name => one([
|
|
33
|
+
for option in aws_acm_certificate.site[0].domain_validation_options : option if option.domain_name == name
|
|
34
|
+
])
|
|
35
|
+
} : {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
resource "aws_route53_record" "validation" {
|
|
39
|
+
for_each = var.hosted_zone == null ? toset([]) : local.certificate_names
|
|
40
|
+
|
|
41
|
+
zone_id = data.aws_route53_zone.site[0].zone_id
|
|
42
|
+
name = local.validation_options[each.key].resource_record_name
|
|
43
|
+
type = local.validation_options[each.key].resource_record_type
|
|
44
|
+
records = [local.validation_options[each.key].resource_record_value]
|
|
45
|
+
ttl = 300
|
|
46
|
+
allow_overwrite = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
resource "aws_acm_certificate_validation" "site" {
|
|
50
|
+
count = local.custom_domain ? 1 : 0
|
|
51
|
+
provider = aws.us_east_1
|
|
52
|
+
certificate_arn = aws_acm_certificate.site[0].arn
|
|
53
|
+
validation_record_fqdns = var.hosted_zone == null ? null : [for record in aws_route53_record.validation : record.fqdn]
|
|
54
|
+
|
|
55
|
+
timeouts {
|
|
56
|
+
create = "2h"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resource "aws_route53_record" "alias" {
|
|
61
|
+
for_each = var.hosted_zone == null ? {} : {
|
|
62
|
+
for pair in setproduct(local.aliases, ["A", "AAAA"]) : "${pair[0]} ${pair[1]}" => {
|
|
63
|
+
name = pair[0]
|
|
64
|
+
type = pair[1]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
zone_id = data.aws_route53_zone.site[0].zone_id
|
|
69
|
+
name = each.value.name
|
|
70
|
+
type = each.value.type
|
|
71
|
+
|
|
72
|
+
alias {
|
|
73
|
+
name = aws_cloudfront_distribution.site.domain_name
|
|
74
|
+
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
|
75
|
+
evaluate_target_health = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Optional CloudFront standard (legacy) access logs. They are delivered with
|
|
2
|
+
# ACLs, so this bucket, unlike the content bucket, keeps ACLs enabled.
|
|
3
|
+
|
|
4
|
+
data "aws_canonical_user_id" "current" {
|
|
5
|
+
count = var.logging ? 1 : 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
resource "aws_s3_bucket" "logs" {
|
|
9
|
+
count = var.logging ? 1 : 0
|
|
10
|
+
bucket_prefix = "${trimsuffix(substr("sforge-${local.id}", 0, 31), "-")}-logs-"
|
|
11
|
+
force_destroy = var.force_destroy
|
|
12
|
+
tags = local.tags
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
resource "aws_s3_bucket_public_access_block" "logs" {
|
|
16
|
+
count = var.logging ? 1 : 0
|
|
17
|
+
bucket = aws_s3_bucket.logs[0].id
|
|
18
|
+
block_public_acls = true
|
|
19
|
+
block_public_policy = true
|
|
20
|
+
ignore_public_acls = true
|
|
21
|
+
restrict_public_buckets = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
resource "aws_s3_bucket_ownership_controls" "logs" {
|
|
25
|
+
count = var.logging ? 1 : 0
|
|
26
|
+
bucket = aws_s3_bucket.logs[0].id
|
|
27
|
+
rule {
|
|
28
|
+
object_ownership = "BucketOwnerPreferred"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
resource "aws_s3_bucket_acl" "logs" {
|
|
33
|
+
count = var.logging ? 1 : 0
|
|
34
|
+
bucket = aws_s3_bucket.logs[0].id
|
|
35
|
+
|
|
36
|
+
access_control_policy {
|
|
37
|
+
owner {
|
|
38
|
+
id = data.aws_canonical_user_id.current[0].id
|
|
39
|
+
}
|
|
40
|
+
grant {
|
|
41
|
+
permission = "FULL_CONTROL"
|
|
42
|
+
grantee {
|
|
43
|
+
type = "CanonicalUser"
|
|
44
|
+
id = data.aws_canonical_user_id.current[0].id
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
grant {
|
|
48
|
+
# The awslogsdelivery account, which writes CloudFront logs.
|
|
49
|
+
permission = "FULL_CONTROL"
|
|
50
|
+
grantee {
|
|
51
|
+
type = "CanonicalUser"
|
|
52
|
+
id = "c4c1ede66af53448b93c283ce9448c4ba468c9432aa01d700d3878632f77d2d0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
depends_on = [aws_s3_bucket_ownership_controls.logs]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
|
|
61
|
+
count = var.logging ? 1 : 0
|
|
62
|
+
bucket = aws_s3_bucket.logs[0].id
|
|
63
|
+
rule {
|
|
64
|
+
apply_server_side_encryption_by_default {
|
|
65
|
+
sse_algorithm = "AES256"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
|
|
71
|
+
count = var.logging ? 1 : 0
|
|
72
|
+
bucket = aws_s3_bucket.logs[0].id
|
|
73
|
+
|
|
74
|
+
rule {
|
|
75
|
+
id = "expire-logs"
|
|
76
|
+
status = "Enabled"
|
|
77
|
+
filter {}
|
|
78
|
+
expiration {
|
|
79
|
+
days = 90
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
output "bucket" {
|
|
2
|
+
description = "Content bucket (private)."
|
|
3
|
+
value = aws_s3_bucket.site.id
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
output "bucket_region" {
|
|
7
|
+
value = aws_s3_bucket.site.region
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
output "distribution_id" {
|
|
11
|
+
value = aws_cloudfront_distribution.site.id
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
output "distribution_arn" {
|
|
15
|
+
value = aws_cloudfront_distribution.site.arn
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
output "distribution_domain" {
|
|
19
|
+
value = aws_cloudfront_distribution.site.domain_name
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
output "kvs_arn" {
|
|
23
|
+
description = "KeyValueStore with the active version, preview hosts and route exceptions."
|
|
24
|
+
value = aws_cloudfront_key_value_store.site.arn
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
output "url" {
|
|
28
|
+
value = "https://${local.custom_domain ? var.domain : aws_cloudfront_distribution.site.domain_name}"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
output "preview_domain" {
|
|
32
|
+
value = var.preview_domain
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
output "trailing_slash" {
|
|
36
|
+
value = var.trailing_slash
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
output "certificate_arn" {
|
|
40
|
+
value = local.custom_domain ? aws_acm_certificate.site[0].arn : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
output "validation_records" {
|
|
44
|
+
description = "DNS records that validate the certificate (create them when there is no hosted_zone)."
|
|
45
|
+
value = local.custom_domain ? [
|
|
46
|
+
for option in aws_acm_certificate.site[0].domain_validation_options : {
|
|
47
|
+
name = option.resource_record_name
|
|
48
|
+
type = option.resource_record_type
|
|
49
|
+
value = option.resource_record_value
|
|
50
|
+
}
|
|
51
|
+
] : []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
output "dns_records" {
|
|
55
|
+
description = "Records pointing the custom domain at CloudFront (created when hosted_zone is set)."
|
|
56
|
+
value = [for name in local.aliases : {
|
|
57
|
+
name = name
|
|
58
|
+
type = "CNAME"
|
|
59
|
+
value = aws_cloudfront_distribution.site.domain_name
|
|
60
|
+
}]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
output "ci_role_arn" {
|
|
64
|
+
value = local.ci == null ? null : aws_iam_role.ci[0].arn
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
output "oidc_provider_arn" {
|
|
68
|
+
value = local.oidc_provider_arn
|
|
69
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// staticforge viewer-request router (CloudFront Functions, cloudfront-js-2.0).
|
|
2
|
+
//
|
|
3
|
+
// 1. Picks the version to serve from the KeyValueStore: "active", or for a PR
|
|
4
|
+
// preview host (pr-42.<previewDomain>) the key "host:pr-42".
|
|
5
|
+
// 2. Maps the URL to an object the way `sforge dev`/`preview` do
|
|
6
|
+
// (resolveRequest in @staticforge/server), by rule:
|
|
7
|
+
// trailingSlash "always": /a/ → a/index.html, /a → 301 /a/
|
|
8
|
+
// trailingSlash "never": /a → a.html, /a/ → 301 /a
|
|
9
|
+
// /file.ext → file.ext; dot-segments and malformed paths → 404
|
|
10
|
+
// Files that break the rule (a section index with "never", an
|
|
11
|
+
// extensionless file, ...) are listed per version in the KVS as
|
|
12
|
+
// "r:<version>:<path>" exceptions by `sforge deploy`.
|
|
13
|
+
// 3. Prefixes the object with v/<version>/. The rewritten URI is the cache
|
|
14
|
+
// key, so a new version never serves stale cached objects.
|
|
15
|
+
|
|
16
|
+
import cf from 'cloudfront';
|
|
17
|
+
|
|
18
|
+
// Replaced at provision time, e.g. {"trailingSlash":"always","previewSuffix":".preview.example.com"}.
|
|
19
|
+
const CONFIG = __SFORGE_CONFIG__;
|
|
20
|
+
const kvs = cf.kvs();
|
|
21
|
+
|
|
22
|
+
async function lookup(key) {
|
|
23
|
+
try {
|
|
24
|
+
return await kvs.get(key);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function route(uri) {
|
|
31
|
+
if (uri.charAt(0) !== '/') return { missing: true };
|
|
32
|
+
const raw = uri.slice(1).split('/');
|
|
33
|
+
const trailing = raw[raw.length - 1] === '';
|
|
34
|
+
if (trailing) raw.pop();
|
|
35
|
+
const canonical = [];
|
|
36
|
+
for (let i = 0; i < raw.length; i++) {
|
|
37
|
+
let segment;
|
|
38
|
+
try {
|
|
39
|
+
segment = decodeURIComponent(raw[i]);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return { missing: true };
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
segment === '' ||
|
|
45
|
+
segment.charAt(0) === '.' ||
|
|
46
|
+
segment.indexOf('\\') !== -1 ||
|
|
47
|
+
segment.indexOf('\0') !== -1
|
|
48
|
+
) {
|
|
49
|
+
return { missing: true };
|
|
50
|
+
}
|
|
51
|
+
canonical.push(encodeURIComponent(segment));
|
|
52
|
+
}
|
|
53
|
+
if (raw.length === 0) return { file: 'index.html' };
|
|
54
|
+
|
|
55
|
+
const path = raw.join('/');
|
|
56
|
+
const key = '/' + canonical.join('/') + (trailing ? '/' : '');
|
|
57
|
+
if (trailing) {
|
|
58
|
+
if (CONFIG.trailingSlash === 'always') return { file: path + '/index.html', key: key };
|
|
59
|
+
// "/style.css/" is not a page; only pages lose their slash.
|
|
60
|
+
return raw[raw.length - 1].indexOf('.') !== -1
|
|
61
|
+
? { missing: true, key: key }
|
|
62
|
+
: { redirect: '/' + path, key: key };
|
|
63
|
+
}
|
|
64
|
+
if (raw[raw.length - 1].indexOf('.') !== -1) return { file: path };
|
|
65
|
+
return CONFIG.trailingSlash === 'always'
|
|
66
|
+
? { redirect: '/' + path + '/', key: key }
|
|
67
|
+
: { file: path + '.html', key: key };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function exception(value) {
|
|
71
|
+
if (value === '404') return { missing: true };
|
|
72
|
+
if (value.slice(0, 2) === 'f:') return { file: value.slice(2) };
|
|
73
|
+
if (value.slice(0, 2) === 'r:') return { redirect: value.slice(2) };
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function queryString(querystring) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
for (const name in querystring) {
|
|
80
|
+
const entry = querystring[name];
|
|
81
|
+
const values = entry.multiValue ? entry.multiValue : [entry];
|
|
82
|
+
for (let i = 0; i < values.length; i++) {
|
|
83
|
+
parts.push(values[i].value === '' ? name : name + '=' + values[i].value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return parts.length > 0 ? '?' + parts.join('&') : '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function textResponse(statusCode, statusDescription, text) {
|
|
90
|
+
return {
|
|
91
|
+
statusCode: statusCode,
|
|
92
|
+
statusDescription: statusDescription,
|
|
93
|
+
headers: {
|
|
94
|
+
'content-type': { value: 'text/plain; charset=utf-8' },
|
|
95
|
+
'cache-control': { value: 'no-store' },
|
|
96
|
+
},
|
|
97
|
+
body: { encoding: 'text', data: text },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handler(event) {
|
|
102
|
+
const request = event.request;
|
|
103
|
+
// The error page fetched by CloudFront's custom error responses; nothing
|
|
104
|
+
// else outside v/<version>/ (such as _sforge/versions.json) is reachable.
|
|
105
|
+
if (request.uri === '/_sforge/errors/404.html') return request;
|
|
106
|
+
|
|
107
|
+
const host = request.headers.host ? request.headers.host.value.toLowerCase() : '';
|
|
108
|
+
const suffix = CONFIG.previewSuffix;
|
|
109
|
+
let key = 'active';
|
|
110
|
+
if (suffix !== '' && host.length > suffix.length && host.slice(-suffix.length) === suffix) {
|
|
111
|
+
key = 'host:' + host.slice(0, host.length - suffix.length);
|
|
112
|
+
}
|
|
113
|
+
const version = await lookup(key);
|
|
114
|
+
if (!version || version === 'none') {
|
|
115
|
+
return key === 'active'
|
|
116
|
+
? textResponse(503, 'Service Unavailable', 'This site has not been deployed yet.')
|
|
117
|
+
: textResponse(404, 'Not Found', 'No preview is deployed at this address.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let target = route(request.uri);
|
|
121
|
+
if (target.key !== undefined) {
|
|
122
|
+
const value = await lookup('r:' + version + ':' + target.key);
|
|
123
|
+
const override = value ? exception(value) : null;
|
|
124
|
+
if (override !== null) target = override;
|
|
125
|
+
}
|
|
126
|
+
if (target.redirect !== undefined) {
|
|
127
|
+
return {
|
|
128
|
+
statusCode: 301,
|
|
129
|
+
statusDescription: 'Moved Permanently',
|
|
130
|
+
headers: { location: { value: target.redirect + queryString(request.querystring) } },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// A key that never exists, so S3 answers 404 and CloudFront serves the 404 page.
|
|
134
|
+
request.uri = '/v/' + version + '/' + (target.missing ? '_sforge/missing' : target.file);
|
|
135
|
+
return request;
|
|
136
|
+
}
|