thinkwork-cli 0.5.4 → 0.6.1
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/README.md +4 -0
- package/dist/cli.js +645 -287
- package/dist/terraform/examples/greenfield/main.tf +156 -2
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +10 -0
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +33 -0
- package/dist/terraform/modules/app/job-triggers/main.tf +21 -0
- package/dist/terraform/modules/app/lambda-api/.build/placeholder.zip +0 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +66 -16
- package/dist/terraform/modules/app/lambda-api/main.tf +120 -2
- package/dist/terraform/modules/app/lambda-api/outputs.tf +20 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +22 -0
- package/dist/terraform/modules/app/ses-email/main.tf +173 -10
- package/dist/terraform/modules/app/static-site/main.tf +37 -14
- package/dist/terraform/modules/app/www-dns/README.md +39 -0
- package/dist/terraform/modules/app/www-dns/main.tf +245 -0
- package/dist/terraform/modules/app/www-dns/outputs.tf +14 -0
- package/dist/terraform/modules/app/www-dns/variables.tf +43 -0
- package/dist/terraform/modules/thinkwork/main.tf +52 -9
- package/dist/terraform/modules/thinkwork/outputs.tf +32 -0
- package/dist/terraform/modules/thinkwork/variables.tf +57 -3
- package/package.json +1 -1
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
################################################################################
|
|
2
2
|
# SES Email — App Module
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Wires up inbound and outbound email for a delegated subdomain
|
|
5
|
+
# (e.g. agents.thinkwork.ai). The module:
|
|
6
|
+
#
|
|
7
|
+
# 1. Creates a Route53 hosted zone for the subdomain (Option A — delegated
|
|
8
|
+
# subzone). The operator pastes the output name servers at whatever hosts
|
|
9
|
+
# the parent domain (Google, Squarespace, Cloudflare, etc.).
|
|
10
|
+
# 2. Creates the SES domain identity + DKIM tokens.
|
|
11
|
+
# 3. Writes the SES verification TXT, DKIM CNAMEs, and an MX record into the
|
|
12
|
+
# new subzone.
|
|
13
|
+
# 4. Creates an SES receipt rule set that stores inbound mail in S3 at
|
|
14
|
+
# `email/inbound/<sesMessageId>` and invokes the email-inbound Lambda.
|
|
7
15
|
################################################################################
|
|
8
16
|
|
|
9
17
|
variable "stage" {
|
|
@@ -16,36 +24,191 @@ variable "account_id" {
|
|
|
16
24
|
type = string
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
variable "region" {
|
|
28
|
+
description = "AWS region (determines the inbound SMTP endpoint)"
|
|
29
|
+
type = string
|
|
30
|
+
default = "us-east-1"
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
variable "email_domain" {
|
|
20
|
-
description = "
|
|
34
|
+
description = "Subdomain used for agent email (e.g. agents.thinkwork.ai). Leave empty to skip all SES resources."
|
|
35
|
+
type = string
|
|
36
|
+
default = ""
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
variable "inbound_bucket_name" {
|
|
40
|
+
description = "S3 bucket that SES writes raw inbound .eml files into. Its policy must already allow ses.amazonaws.com PutObject."
|
|
21
41
|
type = string
|
|
22
42
|
default = ""
|
|
23
43
|
}
|
|
24
44
|
|
|
45
|
+
variable "email_inbound_fn_arn" {
|
|
46
|
+
description = "ARN of the email-inbound Lambda. If empty, receipt rule is still created but without a Lambda action."
|
|
47
|
+
type = string
|
|
48
|
+
default = ""
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
variable "email_inbound_fn_name" {
|
|
52
|
+
description = "Function name of the email-inbound Lambda (for the Lambda permission)."
|
|
53
|
+
type = string
|
|
54
|
+
default = ""
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
variable "manage_active_rule_set" {
|
|
58
|
+
description = "Activate the receipt rule set. Only ONE rule set can be active per region per account, so set false in secondary stages that share an account."
|
|
59
|
+
type = bool
|
|
60
|
+
default = true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
locals {
|
|
64
|
+
enabled = var.email_domain != ""
|
|
65
|
+
inbound_smtp = "inbound-smtp.${var.region}.amazonaws.com"
|
|
66
|
+
rule_set_name = "thinkwork-${var.stage}-email-rules"
|
|
67
|
+
has_lambda = var.email_inbound_fn_arn != ""
|
|
68
|
+
has_bucket = var.inbound_bucket_name != ""
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
################################################################################
|
|
72
|
+
# Route53 — delegated subzone for the agent email subdomain
|
|
73
|
+
################################################################################
|
|
74
|
+
|
|
75
|
+
resource "aws_route53_zone" "agents" {
|
|
76
|
+
count = local.enabled ? 1 : 0
|
|
77
|
+
name = var.email_domain
|
|
78
|
+
|
|
79
|
+
tags = {
|
|
80
|
+
Name = "thinkwork-${var.stage}-email-zone"
|
|
81
|
+
Stage = var.stage
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
25
85
|
################################################################################
|
|
26
|
-
# SES Domain Identity
|
|
86
|
+
# SES Domain Identity + DKIM
|
|
27
87
|
################################################################################
|
|
28
88
|
|
|
29
89
|
resource "aws_ses_domain_identity" "main" {
|
|
30
|
-
count =
|
|
90
|
+
count = local.enabled ? 1 : 0
|
|
31
91
|
domain = var.email_domain
|
|
32
92
|
}
|
|
33
93
|
|
|
34
94
|
resource "aws_ses_domain_dkim" "main" {
|
|
35
|
-
count =
|
|
95
|
+
count = local.enabled ? 1 : 0
|
|
36
96
|
domain = aws_ses_domain_identity.main[0].domain
|
|
37
97
|
}
|
|
38
98
|
|
|
99
|
+
################################################################################
|
|
100
|
+
# DNS records in the subzone — verification TXT, DKIM CNAMEs, MX
|
|
101
|
+
################################################################################
|
|
102
|
+
|
|
103
|
+
resource "aws_route53_record" "ses_verification" {
|
|
104
|
+
count = local.enabled ? 1 : 0
|
|
105
|
+
zone_id = aws_route53_zone.agents[0].zone_id
|
|
106
|
+
name = "_amazonses.${var.email_domain}"
|
|
107
|
+
type = "TXT"
|
|
108
|
+
ttl = 600
|
|
109
|
+
records = [aws_ses_domain_identity.main[0].verification_token]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
resource "aws_route53_record" "dkim" {
|
|
113
|
+
count = local.enabled ? 3 : 0
|
|
114
|
+
zone_id = aws_route53_zone.agents[0].zone_id
|
|
115
|
+
name = "${aws_ses_domain_dkim.main[0].dkim_tokens[count.index]}._domainkey.${var.email_domain}"
|
|
116
|
+
type = "CNAME"
|
|
117
|
+
ttl = 600
|
|
118
|
+
records = ["${aws_ses_domain_dkim.main[0].dkim_tokens[count.index]}.dkim.amazonses.com"]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
resource "aws_route53_record" "mx" {
|
|
122
|
+
count = local.enabled ? 1 : 0
|
|
123
|
+
zone_id = aws_route53_zone.agents[0].zone_id
|
|
124
|
+
name = var.email_domain
|
|
125
|
+
type = "MX"
|
|
126
|
+
ttl = 600
|
|
127
|
+
records = ["10 ${local.inbound_smtp}"]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
################################################################################
|
|
131
|
+
# SES Receipt Rule Set + Rule → S3 + Lambda
|
|
132
|
+
################################################################################
|
|
133
|
+
|
|
134
|
+
resource "aws_ses_receipt_rule_set" "main" {
|
|
135
|
+
count = local.enabled ? 1 : 0
|
|
136
|
+
rule_set_name = local.rule_set_name
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
resource "aws_ses_active_receipt_rule_set" "main" {
|
|
140
|
+
count = local.enabled && var.manage_active_rule_set ? 1 : 0
|
|
141
|
+
rule_set_name = aws_ses_receipt_rule_set.main[0].rule_set_name
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
resource "aws_lambda_permission" "ses_invoke_email_inbound" {
|
|
145
|
+
count = local.enabled && local.has_lambda ? 1 : 0
|
|
146
|
+
statement_id = "AllowSESInvokeEmailInbound"
|
|
147
|
+
action = "lambda:InvokeFunction"
|
|
148
|
+
function_name = var.email_inbound_fn_name
|
|
149
|
+
principal = "ses.amazonaws.com"
|
|
150
|
+
source_account = var.account_id
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
resource "aws_ses_receipt_rule" "inbound" {
|
|
154
|
+
count = local.enabled ? 1 : 0
|
|
155
|
+
name = "thinkwork-${var.stage}-inbound-email"
|
|
156
|
+
rule_set_name = aws_ses_receipt_rule_set.main[0].rule_set_name
|
|
157
|
+
recipients = [var.email_domain]
|
|
158
|
+
enabled = true
|
|
159
|
+
scan_enabled = true
|
|
160
|
+
|
|
161
|
+
dynamic "s3_action" {
|
|
162
|
+
for_each = local.has_bucket ? [1] : []
|
|
163
|
+
content {
|
|
164
|
+
bucket_name = var.inbound_bucket_name
|
|
165
|
+
object_key_prefix = "email/inbound/"
|
|
166
|
+
position = 1
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dynamic "lambda_action" {
|
|
171
|
+
for_each = local.has_lambda ? [1] : []
|
|
172
|
+
content {
|
|
173
|
+
function_arn = var.email_inbound_fn_arn
|
|
174
|
+
invocation_type = "Event"
|
|
175
|
+
position = local.has_bucket ? 2 : 1
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
depends_on = [aws_lambda_permission.ses_invoke_email_inbound]
|
|
180
|
+
}
|
|
181
|
+
|
|
39
182
|
################################################################################
|
|
40
183
|
# Outputs
|
|
41
184
|
################################################################################
|
|
42
185
|
|
|
43
186
|
output "ses_domain_identity_arn" {
|
|
44
187
|
description = "SES domain identity ARN"
|
|
45
|
-
value =
|
|
188
|
+
value = local.enabled ? aws_ses_domain_identity.main[0].arn : null
|
|
46
189
|
}
|
|
47
190
|
|
|
48
191
|
output "dkim_tokens" {
|
|
49
|
-
description = "DKIM tokens
|
|
50
|
-
value =
|
|
192
|
+
description = "DKIM tokens (already written as CNAMEs in the subzone)"
|
|
193
|
+
value = local.enabled ? aws_ses_domain_dkim.main[0].dkim_tokens : []
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
output "zone_id" {
|
|
197
|
+
description = "Route53 hosted zone ID for the email subdomain"
|
|
198
|
+
value = local.enabled ? aws_route53_zone.agents[0].zone_id : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
output "name_servers" {
|
|
202
|
+
description = "Name servers for the delegated email subzone. Paste these as NS records at the registrar that hosts the parent domain (e.g. Google Domains) before SES can verify."
|
|
203
|
+
value = local.enabled ? aws_route53_zone.agents[0].name_servers : []
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
output "mx_target" {
|
|
207
|
+
description = "MX target host for the email subdomain"
|
|
208
|
+
value = local.enabled ? local.inbound_smtp : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
output "rule_set_name" {
|
|
212
|
+
description = "SES receipt rule set name"
|
|
213
|
+
value = local.enabled ? aws_ses_receipt_rule_set.main[0].rule_set_name : null
|
|
51
214
|
}
|
|
@@ -33,6 +33,12 @@ variable "certificate_arn" {
|
|
|
33
33
|
default = ""
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
variable "is_spa" {
|
|
37
|
+
description = "When true, configure CloudFront for a single-page app: drop the directory-rewrite function and fall back to /index.html with 200 on 403/404 so the client router can handle deep links."
|
|
38
|
+
type = bool
|
|
39
|
+
default = false
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
################################################################################
|
|
37
43
|
# S3 Bucket
|
|
38
44
|
################################################################################
|
|
@@ -74,6 +80,11 @@ resource "aws_cloudfront_origin_access_control" "site" {
|
|
|
74
80
|
#
|
|
75
81
|
# S3 with OAC doesn't auto-serve index.html for subdirectory requests.
|
|
76
82
|
# /getting-started/ → /getting-started/index.html
|
|
83
|
+
#
|
|
84
|
+
# Always created (so flipping is_spa on an existing distribution doesn't hit
|
|
85
|
+
# CloudFront's "can't delete a function still associated with a distribution"
|
|
86
|
+
# error), but the viewer-request association is only wired up for non-SPA
|
|
87
|
+
# sites. For SPAs we rely on the 403/404 → /index.html fallback below.
|
|
77
88
|
################################################################################
|
|
78
89
|
|
|
79
90
|
resource "aws_cloudfront_function" "rewrite" {
|
|
@@ -94,6 +105,19 @@ resource "aws_cloudfront_function" "rewrite" {
|
|
|
94
105
|
EOF
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
locals {
|
|
109
|
+
# For SPAs, 403/404 from S3 means "not a real asset" — serve index.html with
|
|
110
|
+
# a 200 so the client router can resolve the route. For directory-style
|
|
111
|
+
# static sites, surface a real 404 page.
|
|
112
|
+
error_responses = var.is_spa ? [
|
|
113
|
+
{ error_code = 403, response_code = 200, response_page_path = "/index.html" },
|
|
114
|
+
{ error_code = 404, response_code = 200, response_page_path = "/index.html" },
|
|
115
|
+
] : [
|
|
116
|
+
{ error_code = 404, response_code = 404, response_page_path = "/404.html" },
|
|
117
|
+
{ error_code = 403, response_code = 404, response_page_path = "/404.html" },
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
################################################################################
|
|
98
122
|
# CloudFront Distribution
|
|
99
123
|
################################################################################
|
|
@@ -117,9 +141,12 @@ resource "aws_cloudfront_distribution" "site" {
|
|
|
117
141
|
cached_methods = ["GET", "HEAD"]
|
|
118
142
|
compress = true
|
|
119
143
|
|
|
120
|
-
function_association {
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
dynamic "function_association" {
|
|
145
|
+
for_each = var.is_spa ? [] : [1]
|
|
146
|
+
content {
|
|
147
|
+
event_type = "viewer-request"
|
|
148
|
+
function_arn = aws_cloudfront_function.rewrite.arn
|
|
149
|
+
}
|
|
123
150
|
}
|
|
124
151
|
|
|
125
152
|
forwarded_values {
|
|
@@ -130,17 +157,13 @@ resource "aws_cloudfront_distribution" "site" {
|
|
|
130
157
|
}
|
|
131
158
|
}
|
|
132
159
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
custom_error_response {
|
|
141
|
-
error_code = 403
|
|
142
|
-
response_code = 404
|
|
143
|
-
response_page_path = "/404.html"
|
|
160
|
+
dynamic "custom_error_response" {
|
|
161
|
+
for_each = local.error_responses
|
|
162
|
+
content {
|
|
163
|
+
error_code = custom_error_response.value.error_code
|
|
164
|
+
response_code = custom_error_response.value.response_code
|
|
165
|
+
response_page_path = custom_error_response.value.response_page_path
|
|
166
|
+
}
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
restrictions {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# www-dns
|
|
2
|
+
|
|
3
|
+
DNS + TLS wiring for the public website, using a Cloudflare-managed zone and an AWS ACM certificate.
|
|
4
|
+
|
|
5
|
+
## What it creates
|
|
6
|
+
|
|
7
|
+
1. ACM certificate in `us-east-1` covering the apex and `www` SAN, DNS-validated via Cloudflare records.
|
|
8
|
+
2. Apex CNAME in Cloudflare pointing at the primary www CloudFront distribution (DNS-only, grey-cloud).
|
|
9
|
+
3. S3 website-redirect bucket + a second CloudFront distribution that 301s `www.<domain>` → `https://<domain>`, plus its Cloudflare CNAME.
|
|
10
|
+
|
|
11
|
+
## Why a second CloudFront distribution instead of a Cloudflare page rule
|
|
12
|
+
|
|
13
|
+
The apex and www records must be **DNS-only** so CloudFront can terminate TLS with the ACM cert. DNS-only traffic bypasses Cloudflare's proxy, so Cloudflare page rules and transform rules never run. The redirect has to live at AWS. An S3 website bucket with `redirect_all_requests_to` is the cheapest, simplest thing that works.
|
|
14
|
+
|
|
15
|
+
## Required environment
|
|
16
|
+
|
|
17
|
+
- `CLOUDFLARE_API_TOKEN` — exported in the shell or CI environment. **Never** committed to tfvars. Rotate after anyone outside the deploy path sees it.
|
|
18
|
+
|
|
19
|
+
## Usage (from greenfield example)
|
|
20
|
+
|
|
21
|
+
```hcl
|
|
22
|
+
provider "cloudflare" {
|
|
23
|
+
# token read from CLOUDFLARE_API_TOKEN env var
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module "www_dns" {
|
|
27
|
+
source = "../../modules/app/www-dns"
|
|
28
|
+
stage = var.stage
|
|
29
|
+
domain = var.www_domain
|
|
30
|
+
cloudflare_zone_id = var.cloudflare_zone_id
|
|
31
|
+
cloudfront_domain_name = module.thinkwork.www_distribution_domain
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then pass `module.www_dns.certificate_arn` back into `module "thinkwork"` as `www_certificate_arn`.
|
|
36
|
+
|
|
37
|
+
## First-apply ordering
|
|
38
|
+
|
|
39
|
+
On a fresh apply, Terraform has to create the primary CloudFront distribution (via `module.thinkwork.www_site`) before this module can reference its domain name. Terraform's dependency graph handles that automatically. If you're applying after a `terraform destroy`, two `apply` passes are fine — the first resolves the cert + distributions, the second binds the apex CNAME once the CloudFront distribution has deployed.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Public Website DNS + TLS (Cloudflare zone, AWS ACM cert, www→apex 301)
|
|
3
|
+
#
|
|
4
|
+
# Responsibilities:
|
|
5
|
+
# 1. ACM certificate in us-east-1 covering apex + www
|
|
6
|
+
# (+ optional docs + optional admin).
|
|
7
|
+
# 2. Cloudflare DNS records for ACM DNS validation.
|
|
8
|
+
# 3. Apex CNAME in Cloudflare → primary CloudFront distribution (DNS-only).
|
|
9
|
+
# 4. Second CloudFront distribution fronting an S3 website-redirect bucket
|
|
10
|
+
# that 301s www.<domain> → https://<domain>, plus its Cloudflare CNAME.
|
|
11
|
+
# 5. Optional docs.<domain> CNAME → docs CloudFront distribution.
|
|
12
|
+
# 6. Optional admin.<domain> CNAME → admin CloudFront distribution.
|
|
13
|
+
#
|
|
14
|
+
# Cloudflare records MUST be DNS-only (grey cloud). CloudFront terminates TLS
|
|
15
|
+
# with the ACM cert and needs the real Host header.
|
|
16
|
+
################################################################################
|
|
17
|
+
|
|
18
|
+
terraform {
|
|
19
|
+
required_providers {
|
|
20
|
+
aws = {
|
|
21
|
+
source = "hashicorp/aws"
|
|
22
|
+
version = "~> 5.0"
|
|
23
|
+
}
|
|
24
|
+
cloudflare = {
|
|
25
|
+
source = "cloudflare/cloudflare"
|
|
26
|
+
version = "~> 4.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
locals {
|
|
32
|
+
apex = var.domain
|
|
33
|
+
www = "www.${var.domain}"
|
|
34
|
+
docs = "docs.${var.domain}"
|
|
35
|
+
admin = "admin.${var.domain}"
|
|
36
|
+
name_id = replace(var.domain, ".", "-")
|
|
37
|
+
|
|
38
|
+
# ACM SANs: always include www, conditionally include docs and admin.
|
|
39
|
+
# Gated on plain bool vars (not on CloudFront outputs) to keep the
|
|
40
|
+
# dependency graph acyclic — distributions depend on the cert, so
|
|
41
|
+
# the cert mustn't depend on distribution outputs.
|
|
42
|
+
cert_sans = concat(
|
|
43
|
+
[local.www],
|
|
44
|
+
var.include_docs ? [local.docs] : [],
|
|
45
|
+
var.include_admin ? [local.admin] : [],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# CNAME records can only be created when we have the distribution
|
|
49
|
+
# domain to point at. Those inputs come after the cert is done, so
|
|
50
|
+
# they don't participate in the cert's dependency graph.
|
|
51
|
+
create_docs_record = var.include_docs && var.docs_cloudfront_domain_name != ""
|
|
52
|
+
create_admin_record = var.include_admin && var.admin_cloudfront_domain_name != ""
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
################################################################################
|
|
56
|
+
# ACM certificate (us-east-1, covers apex + www [+ docs])
|
|
57
|
+
################################################################################
|
|
58
|
+
|
|
59
|
+
resource "aws_acm_certificate" "www" {
|
|
60
|
+
domain_name = local.apex
|
|
61
|
+
subject_alternative_names = local.cert_sans
|
|
62
|
+
validation_method = "DNS"
|
|
63
|
+
|
|
64
|
+
lifecycle {
|
|
65
|
+
create_before_destroy = true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
tags = {
|
|
69
|
+
Name = "thinkwork-${var.stage}-${local.name_id}"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resource "cloudflare_record" "acm_validation" {
|
|
74
|
+
for_each = {
|
|
75
|
+
for dvo in aws_acm_certificate.www.domain_validation_options : dvo.domain_name => {
|
|
76
|
+
name = dvo.resource_record_name
|
|
77
|
+
value = dvo.resource_record_value
|
|
78
|
+
type = dvo.resource_record_type
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
zone_id = var.cloudflare_zone_id
|
|
83
|
+
name = trimsuffix(each.value.name, ".")
|
|
84
|
+
content = trimsuffix(each.value.value, ".")
|
|
85
|
+
type = each.value.type
|
|
86
|
+
ttl = 60
|
|
87
|
+
proxied = false
|
|
88
|
+
comment = "ACM DNS validation for ${each.key}"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
resource "aws_acm_certificate_validation" "www" {
|
|
92
|
+
certificate_arn = aws_acm_certificate.www.arn
|
|
93
|
+
validation_record_fqdns = [for r in cloudflare_record.acm_validation : r.hostname]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
################################################################################
|
|
97
|
+
# Apex DNS → primary CloudFront distribution
|
|
98
|
+
#
|
|
99
|
+
# Cloudflare flattens apex CNAMEs automatically. proxied=false is required so
|
|
100
|
+
# CloudFront sees the real Host header and the ACM cert matches.
|
|
101
|
+
################################################################################
|
|
102
|
+
|
|
103
|
+
resource "cloudflare_record" "apex" {
|
|
104
|
+
zone_id = var.cloudflare_zone_id
|
|
105
|
+
name = local.apex
|
|
106
|
+
content = var.cloudfront_domain_name
|
|
107
|
+
type = "CNAME"
|
|
108
|
+
ttl = 300
|
|
109
|
+
proxied = false
|
|
110
|
+
comment = "thinkwork-${var.stage} apex → CloudFront (www)"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
################################################################################
|
|
114
|
+
# www.<domain> → apex 301 redirect
|
|
115
|
+
#
|
|
116
|
+
# Uses an S3 website-redirect bucket (website endpoint, not REST). Fronted by
|
|
117
|
+
# its own CloudFront distribution with the www alias and the same ACM cert.
|
|
118
|
+
################################################################################
|
|
119
|
+
|
|
120
|
+
resource "aws_s3_bucket" "www_redirect" {
|
|
121
|
+
bucket = "thinkwork-${var.stage}-www-redirect"
|
|
122
|
+
|
|
123
|
+
tags = {
|
|
124
|
+
Name = "thinkwork-${var.stage}-www-redirect"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
resource "aws_s3_bucket_public_access_block" "www_redirect" {
|
|
129
|
+
bucket = aws_s3_bucket.www_redirect.id
|
|
130
|
+
|
|
131
|
+
block_public_acls = true
|
|
132
|
+
block_public_policy = true
|
|
133
|
+
ignore_public_acls = true
|
|
134
|
+
restrict_public_buckets = true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
resource "aws_s3_bucket_website_configuration" "www_redirect" {
|
|
138
|
+
bucket = aws_s3_bucket.www_redirect.id
|
|
139
|
+
|
|
140
|
+
redirect_all_requests_to {
|
|
141
|
+
host_name = local.apex
|
|
142
|
+
protocol = "https"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
resource "aws_cloudfront_distribution" "www_redirect" {
|
|
147
|
+
enabled = true
|
|
148
|
+
aliases = [local.www]
|
|
149
|
+
price_class = "PriceClass_100"
|
|
150
|
+
is_ipv6_enabled = true
|
|
151
|
+
comment = "thinkwork-${var.stage}-www-redirect → ${local.apex}"
|
|
152
|
+
|
|
153
|
+
origin {
|
|
154
|
+
domain_name = aws_s3_bucket_website_configuration.www_redirect.website_endpoint
|
|
155
|
+
origin_id = "s3-website-${aws_s3_bucket.www_redirect.id}"
|
|
156
|
+
|
|
157
|
+
custom_origin_config {
|
|
158
|
+
http_port = 80
|
|
159
|
+
https_port = 443
|
|
160
|
+
origin_protocol_policy = "http-only"
|
|
161
|
+
origin_ssl_protocols = ["TLSv1.2"]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
default_cache_behavior {
|
|
166
|
+
target_origin_id = "s3-website-${aws_s3_bucket.www_redirect.id}"
|
|
167
|
+
viewer_protocol_policy = "redirect-to-https"
|
|
168
|
+
allowed_methods = ["GET", "HEAD"]
|
|
169
|
+
cached_methods = ["GET", "HEAD"]
|
|
170
|
+
compress = true
|
|
171
|
+
|
|
172
|
+
forwarded_values {
|
|
173
|
+
query_string = false
|
|
174
|
+
cookies {
|
|
175
|
+
forward = "none"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
min_ttl = 0
|
|
180
|
+
default_ttl = 3600
|
|
181
|
+
max_ttl = 86400
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
restrictions {
|
|
185
|
+
geo_restriction {
|
|
186
|
+
restriction_type = "none"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
viewer_certificate {
|
|
191
|
+
acm_certificate_arn = aws_acm_certificate_validation.www.certificate_arn
|
|
192
|
+
ssl_support_method = "sni-only"
|
|
193
|
+
minimum_protocol_version = "TLSv1.2_2021"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
tags = {
|
|
197
|
+
Name = "thinkwork-${var.stage}-www-redirect"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
resource "cloudflare_record" "www_redirect" {
|
|
202
|
+
zone_id = var.cloudflare_zone_id
|
|
203
|
+
name = local.www
|
|
204
|
+
content = aws_cloudfront_distribution.www_redirect.domain_name
|
|
205
|
+
type = "CNAME"
|
|
206
|
+
ttl = 300
|
|
207
|
+
proxied = false
|
|
208
|
+
comment = "thinkwork-${var.stage} www → redirect distribution"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
################################################################################
|
|
212
|
+
# docs.<domain> → docs CloudFront distribution (optional)
|
|
213
|
+
#
|
|
214
|
+
# Created only when the greenfield stack wires docs_cloudfront_domain_name.
|
|
215
|
+
# The docs CloudFront alias picks up the same ACM cert via certificate_arn
|
|
216
|
+
# on the thinkwork module's docs_site.
|
|
217
|
+
################################################################################
|
|
218
|
+
|
|
219
|
+
resource "cloudflare_record" "docs" {
|
|
220
|
+
count = local.create_docs_record ? 1 : 0
|
|
221
|
+
|
|
222
|
+
zone_id = var.cloudflare_zone_id
|
|
223
|
+
name = local.docs
|
|
224
|
+
content = var.docs_cloudfront_domain_name
|
|
225
|
+
type = "CNAME"
|
|
226
|
+
ttl = 300
|
|
227
|
+
proxied = false
|
|
228
|
+
comment = "thinkwork-${var.stage} docs → CloudFront"
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
################################################################################
|
|
232
|
+
# admin.<domain> → admin CloudFront distribution (optional)
|
|
233
|
+
################################################################################
|
|
234
|
+
|
|
235
|
+
resource "cloudflare_record" "admin" {
|
|
236
|
+
count = local.create_admin_record ? 1 : 0
|
|
237
|
+
|
|
238
|
+
zone_id = var.cloudflare_zone_id
|
|
239
|
+
name = local.admin
|
|
240
|
+
content = var.admin_cloudfront_domain_name
|
|
241
|
+
type = "CNAME"
|
|
242
|
+
ttl = 300
|
|
243
|
+
proxied = false
|
|
244
|
+
comment = "thinkwork-${var.stage} admin → CloudFront"
|
|
245
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
output "certificate_arn" {
|
|
2
|
+
description = "ACM certificate ARN (us-east-1) covering apex + www. Pass this to the static-site module via www_certificate_arn."
|
|
3
|
+
value = aws_acm_certificate_validation.www.certificate_arn
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
output "www_redirect_distribution_id" {
|
|
7
|
+
description = "CloudFront distribution ID for the www→apex redirect"
|
|
8
|
+
value = aws_cloudfront_distribution.www_redirect.id
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
output "www_redirect_distribution_domain" {
|
|
12
|
+
description = "CloudFront distribution domain for the www→apex redirect"
|
|
13
|
+
value = aws_cloudfront_distribution.www_redirect.domain_name
|
|
14
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
variable "stage" {
|
|
2
|
+
description = "Deployment stage (used for resource naming)"
|
|
3
|
+
type = string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "domain" {
|
|
7
|
+
description = "Apex domain served by the public website (e.g. thinkwork.ai)"
|
|
8
|
+
type = string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
variable "cloudflare_zone_id" {
|
|
12
|
+
description = "Cloudflare zone ID for the apex domain. Non-secret; lives in tfvars."
|
|
13
|
+
type = string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
variable "cloudfront_domain_name" {
|
|
17
|
+
description = "CloudFront distribution domain name for the primary www site (e.g. d123.cloudfront.net). Passed in from the static-site module output."
|
|
18
|
+
type = string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
variable "include_docs" {
|
|
22
|
+
description = "When true, add docs.<domain> to the ACM cert SANs and create a Cloudflare CNAME for it. Separated from docs_cloudfront_domain_name to avoid a Terraform dependency cycle (distribution depends on cert, so the cert can't depend on the distribution output)."
|
|
23
|
+
type = bool
|
|
24
|
+
default = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
variable "docs_cloudfront_domain_name" {
|
|
28
|
+
description = "CloudFront distribution domain name for the docs site. Used as the target for the docs.<domain> Cloudflare CNAME when include_docs is true."
|
|
29
|
+
type = string
|
|
30
|
+
default = ""
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
variable "include_admin" {
|
|
34
|
+
description = "When true, add admin.<domain> to the ACM cert SANs and create a Cloudflare CNAME for it. Same cycle-avoidance rationale as include_docs."
|
|
35
|
+
type = bool
|
|
36
|
+
default = false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
variable "admin_cloudfront_domain_name" {
|
|
40
|
+
description = "CloudFront distribution domain name for the admin SPA. Used as the target for the admin.<domain> Cloudflare CNAME when include_admin is true."
|
|
41
|
+
type = string
|
|
42
|
+
default = ""
|
|
43
|
+
}
|