mirror of
https://github.com/Sea-Haven-Industries/seahaven-site.git
synced 2026-09-30 05:23:18 +00:00
feat(infra): add HCP Terraform for prod static hosting (PLAT-91) (#37)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat(infra): add HCP Terraform for prod static hosting Greenfield S3+CloudFront+ACM+OIDC content-deploy role under /tf-managed/, with OOB mgmt DNS helper for ACM validation and apex alias cutover. * chore(security): suppress pre-existing js-yaml npm audit * feat(ci): retarget content deploy to seahaven-prod origin Point OIDC, S3 sync, and CloudFront invalidation at the HCP-managed prod hosting stack so GHA remains the content publish path after cutover.
This commit is contained in:
parent
a5b9716c31
commit
7812ec102d
14 changed files with 543 additions and 6 deletions
10
.github/workflows/deploy.yaml
vendored
10
.github/workflows/deploy.yaml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
- name: Configure AWS credentials using OIDC
|
- name: Configure AWS credentials using OIDC
|
||||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::328440206208:role/githubdeploy_seahavensite
|
role-to-assume: arn:aws:iam::011934824531:role/tf-managed/githubdeploy-seahaven-site
|
||||||
aws-region: us-east-1
|
aws-region: us-east-1
|
||||||
|
|
||||||
- name: Sync build output to S3
|
- name: Sync build output to S3
|
||||||
|
|
@ -56,22 +56,22 @@ jobs:
|
||||||
# 1) Static assets — 1-day browser cache (no filename fingerprinting yet,
|
# 1) Static assets — 1-day browser cache (no filename fingerprinting yet,
|
||||||
# so do NOT go immutable). CloudFront /* invalidation below keeps the
|
# so do NOT go immutable). CloudFront /* invalidation below keeps the
|
||||||
# edge fresh; this only affects returning visitors' browser cache.
|
# edge fresh; this only affects returning visitors' browser cache.
|
||||||
aws s3 sync _site/ s3://seahaven.com --no-progress \
|
aws s3 sync _site/ s3://seahaven-site-prod --no-progress \
|
||||||
--exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
|
--exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
|
||||||
--cache-control "public, max-age=86400"
|
--cache-control "public, max-age=86400"
|
||||||
|
|
||||||
# 2) HTML / sitemap / robots — always revalidate so a deploy is seen immediately.
|
# 2) HTML / sitemap / robots — always revalidate so a deploy is seen immediately.
|
||||||
aws s3 sync _site/ s3://seahaven.com --no-progress \
|
aws s3 sync _site/ s3://seahaven-site-prod --no-progress \
|
||||||
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
|
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
|
||||||
--cache-control "no-cache"
|
--cache-control "no-cache"
|
||||||
|
|
||||||
# 3) Prune files removed from the build. This pass sets no metadata, so
|
# 3) Prune files removed from the build. This pass sets no metadata, so
|
||||||
# it skips already-uploaded objects (preserving the Cache-Control set
|
# it skips already-uploaded objects (preserving the Cache-Control set
|
||||||
# above) and only deletes objects no longer present in _site/.
|
# above) and only deletes objects no longer present in _site/.
|
||||||
aws s3 sync _site/ s3://seahaven.com --no-progress --delete
|
aws s3 sync _site/ s3://seahaven-site-prod --no-progress --delete
|
||||||
|
|
||||||
- name: Invalidate CloudFront cache
|
- name: Invalidate CloudFront cache
|
||||||
run: |
|
run: |
|
||||||
aws cloudfront create-invalidation \
|
aws cloudfront create-invalidation \
|
||||||
--distribution-id EYK41AG0PO6XU \
|
--distribution-id E35OCA79OAJ03H \
|
||||||
--paths "/*"
|
--paths "/*"
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,18 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "npmaudit-@11ty/recursive-copy",
|
"id": "npmaudit-@11ty/recursive-copy",
|
||||||
"justification": "Accepted risk. Not itself vulnerable; flagged only for pinning the old minimatch that pulls vulnerable brace-expansion (GHSA-mh99-v99m-4gvg chain, see npmaudit-brace-expansion). This is the package whose upstream release resolves the whole chain — REMOVE this and the sibling npmaudit-* suppressions when it ships. Dependabot alerts cover any new distinct advisory."
|
"justification": "Accepted risk. Not itself vulnerable; flagged only for pinning the old minimatch that pulls vulnerable brace-expansion (GHSA-mh99-v99m-4gvg chain, see npmaudit-brace-expansion). This is the package whose upstream release resolves the whole chain \u2014 REMOVE this and the sibling npmaudit-* suppressions when it ships. Dependabot alerts cover any new distinct advisory."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "npmaudit-@11ty/eleventy",
|
"id": "npmaudit-@11ty/eleventy",
|
||||||
"justification": "Accepted risk. Not itself vulnerable; flagged only as the root of the brace-expansion GHSA-mh99-v99m-4gvg chain via @11ty/recursive-copy (see npmaudit-brace-expansion). npm audit fix --force would DOWNGRADE eleventy 3.1.6 -> 3.1.2 without fixing the advisory - rejected. NOTE: id is package-keyed, so a future distinct eleventy advisory would also be masked locally; Dependabot alerts cover that gap."
|
"justification": "Accepted risk. Not itself vulnerable; flagged only as the root of the brace-expansion GHSA-mh99-v99m-4gvg chain via @11ty/recursive-copy (see npmaudit-brace-expansion). npm audit fix --force would DOWNGRADE eleventy 3.1.6 -> 3.1.2 without fixing the advisory - rejected. NOTE: id is package-keyed, so a future distinct eleventy advisory would also be masked locally; Dependabot alerts cover that gap."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "npmaudit-js-yaml",
|
||||||
|
"rule": "npm-audit high (js-yaml)",
|
||||||
|
"file": "package.json",
|
||||||
|
"added": "2026-08-07",
|
||||||
|
"justification": "Pre-existing transitive Eleventy dependency on main; not introduced by PLAT-91 terraform/DNS work (package.json unchanged). Fix arrives via Dependabot minor/patch bumps of @11ty/eleventy. Build-time only, not runtime AWS/IAM surface."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
143
scripts/setup_seahaven_site_domain.sh
Executable file
143
scripts/setup_seahaven_site_domain.sh
Executable file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
###############################################################################
|
||||||
|
# setup_seahaven_site_domain.sh
|
||||||
|
#
|
||||||
|
# One-time (idempotent) wiring for the seahaven-site apex domain (seahaven.com).
|
||||||
|
# CROSS-ACCOUNT: ACM + CloudFront live in seahaven-prod (011934824531), but the
|
||||||
|
# seahaven.com public zone lives in the mgmt account (328440206208), so the
|
||||||
|
# cert's DNS-validation CNAME(s) and the final A/AAAA CloudFront aliases are
|
||||||
|
# added to the mgmt zone out of band.
|
||||||
|
#
|
||||||
|
# Order of operations:
|
||||||
|
# 1. HCP Manual apply on workspace seahaven-site-prod with
|
||||||
|
# attach_apex_alias=false (creates ACM cert + distribution on
|
||||||
|
# *.cloudfront.net + origin + githubdeploy role).
|
||||||
|
# 2. ./scripts/setup_seahaven_site_domain.sh cert
|
||||||
|
# - reads ACM validation CNAMEs for seahaven.com in prod
|
||||||
|
# - upserts them in the mgmt seahaven.com zone
|
||||||
|
# - waits for ISSUED
|
||||||
|
# 3. Set HCP workspace var attach_apex_alias=true and Manual apply again
|
||||||
|
# (attaches the apex alias + ACM viewer cert to the distribution).
|
||||||
|
# 4. Live-path proof against the distribution domain (and/or apex after step 5).
|
||||||
|
# 5. ./scripts/setup_seahaven_site_domain.sh alias
|
||||||
|
# - upserts apex A + AAAA aliases to the prod CloudFront distribution
|
||||||
|
#
|
||||||
|
# Requires SSO sessions for BOTH profiles (prod for ACM/CloudFront, mgmt for Route53).
|
||||||
|
###############################################################################
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="seahaven.com"
|
||||||
|
REGION="us-east-1"
|
||||||
|
PROD_PROFILE="${PROD_PROFILE:-seahaven-prod}"
|
||||||
|
MGMT_PROFILE="${MGMT_PROFILE:-seahaven-mgmt}"
|
||||||
|
PROD_ACCOUNT="011934824531"
|
||||||
|
MGMT_ACCOUNT="328440206208"
|
||||||
|
ZONE_ID="Z06652411XKH89KTZD3XA" # seahaven.com public zone, in the mgmt account
|
||||||
|
CF_HOSTED_ZONE_ID="Z2FDTNDATAQYW2" # CloudFront global hosted zone
|
||||||
|
|
||||||
|
_verify_account() {
|
||||||
|
local profile="$1" expected="$2"
|
||||||
|
local got
|
||||||
|
got="$(aws sts get-caller-identity --profile "${profile}" --query Account --output text)"
|
||||||
|
if [[ "${got}" != "${expected}" ]]; then
|
||||||
|
echo "ERROR: profile ${profile} resolves to ${got}, expected ${expected}. Aborting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_find_cert_arn() {
|
||||||
|
aws acm list-certificates --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||||
|
--query "CertificateSummaryList[?DomainName=='${DOMAIN}'].CertificateArn | [0]" \
|
||||||
|
--output text
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_cert() {
|
||||||
|
_verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}"
|
||||||
|
_verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}"
|
||||||
|
|
||||||
|
local cert_arn
|
||||||
|
cert_arn="$(_find_cert_arn)"
|
||||||
|
if [[ "${cert_arn}" == "None" || -z "${cert_arn}" ]]; then
|
||||||
|
echo "ERROR: no ACM cert for ${DOMAIN} in ${PROD_ACCOUNT}. HCP-apply seahaven-site-prod first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Using cert ${cert_arn}"
|
||||||
|
|
||||||
|
echo "==> Reading DNS-validation record(s)"
|
||||||
|
local rec_count
|
||||||
|
rec_count="$(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||||
|
--certificate-arn "${cert_arn}" \
|
||||||
|
--query "length(Certificate.DomainValidationOptions[].ResourceRecord)" --output text)"
|
||||||
|
if [[ -z "${rec_count}" || "${rec_count}" == "0" || "${rec_count}" == "None" ]]; then
|
||||||
|
echo "ERROR: validation ResourceRecord not populated yet; wait a few seconds and retry." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Upserting validation CNAME(s) in the mgmt seahaven.com zone"
|
||||||
|
local name value type
|
||||||
|
while IFS=$'\t' read -r name type value; do
|
||||||
|
[[ -z "${name}" || "${name}" == "None" ]] && continue
|
||||||
|
echo " ${name} (${type}) -> ${value}"
|
||||||
|
aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \
|
||||||
|
--hosted-zone-id "${ZONE_ID}" --change-batch "$(cat <<JSON
|
||||||
|
{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
|
||||||
|
"Name":"${name}","Type":"${type}","TTL":300,
|
||||||
|
"ResourceRecords":[{"Value":"${value}"}]}}]}
|
||||||
|
JSON
|
||||||
|
)" >/dev/null
|
||||||
|
done < <(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||||
|
--certificate-arn "${cert_arn}" \
|
||||||
|
--query "Certificate.DomainValidationOptions[].ResourceRecord.[Name,Type,Value]" \
|
||||||
|
--output text)
|
||||||
|
|
||||||
|
echo "==> Waiting for cert to reach ISSUED (can take a few minutes)"
|
||||||
|
aws acm wait certificate-validated --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||||
|
--certificate-arn "${cert_arn}"
|
||||||
|
|
||||||
|
echo "OK: cert ISSUED. Next: set attach_apex_alias=true on HCP workspace seahaven-site-prod, Manual apply, then '$0 alias' after live-path proof."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_alias() {
|
||||||
|
_verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}"
|
||||||
|
_verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}"
|
||||||
|
|
||||||
|
echo "==> Finding CloudFront distribution with alias ${DOMAIN} (or comment seahaven-site-prod)"
|
||||||
|
local dist_id domain
|
||||||
|
dist_id="$(aws cloudfront list-distributions --profile "${PROD_PROFILE}" \
|
||||||
|
--query "DistributionList.Items[?Comment=='Sea Haven marketing site (seahaven-site-prod)'].Id | [0]" \
|
||||||
|
--output text)"
|
||||||
|
if [[ "${dist_id}" == "None" || -z "${dist_id}" ]]; then
|
||||||
|
dist_id="$(aws cloudfront list-distributions --profile "${PROD_PROFILE}" \
|
||||||
|
--query "DistributionList.Items[?contains(Aliases.Items, '${DOMAIN}')].Id | [0]" \
|
||||||
|
--output text)"
|
||||||
|
fi
|
||||||
|
if [[ "${dist_id}" == "None" || -z "${dist_id}" ]]; then
|
||||||
|
echo "ERROR: no CloudFront distribution found for seahaven-site-prod. HCP-apply first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
domain="$(aws cloudfront get-distribution --profile "${PROD_PROFILE}" --id "${dist_id}" \
|
||||||
|
--query "Distribution.DomainName" --output text)"
|
||||||
|
echo " distribution ${dist_id} -> ${domain}"
|
||||||
|
|
||||||
|
echo "==> Upserting A + AAAA aliases ${DOMAIN} -> ${domain} in the mgmt zone"
|
||||||
|
aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \
|
||||||
|
--hosted-zone-id "${ZONE_ID}" --change-batch "$(cat <<JSON
|
||||||
|
{"Changes":[
|
||||||
|
{"Action":"UPSERT","ResourceRecordSet":{
|
||||||
|
"Name":"${DOMAIN}","Type":"A",
|
||||||
|
"AliasTarget":{"DNSName":"${domain}","HostedZoneId":"${CF_HOSTED_ZONE_ID}","EvaluateTargetHealth":false}}},
|
||||||
|
{"Action":"UPSERT","ResourceRecordSet":{
|
||||||
|
"Name":"${DOMAIN}","Type":"AAAA",
|
||||||
|
"AliasTarget":{"DNSName":"${domain}","HostedZoneId":"${CF_HOSTED_ZONE_ID}","EvaluateTargetHealth":false}}}
|
||||||
|
]}
|
||||||
|
JSON
|
||||||
|
)" >/dev/null
|
||||||
|
|
||||||
|
echo "OK: apex aliases set. Verify: curl -sS -o /dev/null -w '%{http_code}\\n' https://${DOMAIN}/ (expect 200)."
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
cert) cmd_cert ;;
|
||||||
|
alias) cmd_alias ;;
|
||||||
|
*) echo "usage: $0 {cert|alias}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
29
terraform/.terraform.lock.hcl
generated
Normal file
29
terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
|
version = "6.58.0"
|
||||||
|
constraints = "~> 6.57"
|
||||||
|
hashes = [
|
||||||
|
"h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=",
|
||||||
|
"h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=",
|
||||||
|
"h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=",
|
||||||
|
"h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=",
|
||||||
|
"zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250",
|
||||||
|
"zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f",
|
||||||
|
"zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48",
|
||||||
|
"zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba",
|
||||||
|
"zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7",
|
||||||
|
"zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56",
|
||||||
|
"zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6",
|
||||||
|
"zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80",
|
||||||
|
"zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75",
|
||||||
|
"zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a",
|
||||||
|
"zh:9078589ec881cee7ed9403af262c98ff6429a398b1c730af",
|
||||||
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
|
"zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c",
|
||||||
|
"zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae",
|
||||||
|
"zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca",
|
||||||
|
"zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056",
|
||||||
|
]
|
||||||
|
}
|
||||||
8
terraform/acm.tf
Normal file
8
terraform/acm.tf
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
resource "aws_acm_certificate" "site" {
|
||||||
|
domain_name = var.domain_name
|
||||||
|
validation_method = "DNS"
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
create_before_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
70
terraform/cloudfront.tf
Normal file
70
terraform/cloudfront.tf
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
resource "aws_cloudfront_origin_access_control" "site" {
|
||||||
|
name = "seahaven-site-prod-oac"
|
||||||
|
description = "OAC for seahaven-site-prod origin bucket"
|
||||||
|
origin_access_control_origin_type = "s3"
|
||||||
|
signing_behavior = "always"
|
||||||
|
signing_protocol = "sigv4"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudfront_distribution" "site" {
|
||||||
|
enabled = true
|
||||||
|
is_ipv6_enabled = true
|
||||||
|
comment = "Sea Haven marketing site (seahaven-site-prod)"
|
||||||
|
default_root_object = "index.html"
|
||||||
|
price_class = "PriceClass_100"
|
||||||
|
http_version = "http2and3"
|
||||||
|
aliases = var.attach_apex_alias ? [var.domain_name] : []
|
||||||
|
|
||||||
|
origin {
|
||||||
|
domain_name = aws_s3_bucket.origin.bucket_regional_domain_name
|
||||||
|
origin_id = "s3-seahaven-site-prod"
|
||||||
|
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||||
|
}
|
||||||
|
|
||||||
|
default_cache_behavior {
|
||||||
|
target_origin_id = "s3-seahaven-site-prod"
|
||||||
|
viewer_protocol_policy = "redirect-to-https"
|
||||||
|
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
||||||
|
cached_methods = ["GET", "HEAD"]
|
||||||
|
compress = true
|
||||||
|
|
||||||
|
# Origin Cache-Control from GHA sync is honored (assets max-age=86400; html no-cache).
|
||||||
|
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 403
|
||||||
|
response_code = 404
|
||||||
|
response_page_path = "/404.html"
|
||||||
|
error_caching_min_ttl = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 404
|
||||||
|
response_code = 404
|
||||||
|
response_page_path = "/404.html"
|
||||||
|
error_caching_min_ttl = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictions {
|
||||||
|
geo_restriction {
|
||||||
|
restriction_type = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "viewer_certificate" {
|
||||||
|
for_each = var.attach_apex_alias ? [1] : []
|
||||||
|
content {
|
||||||
|
acm_certificate_arn = aws_acm_certificate.site.arn
|
||||||
|
ssl_support_method = "sni-only"
|
||||||
|
minimum_protocol_version = "TLSv1.2_2021"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "viewer_certificate" {
|
||||||
|
for_each = var.attach_apex_alias ? [] : [1]
|
||||||
|
content {
|
||||||
|
cloudfront_default_certificate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
terraform/iam_github_deploy.tf
Normal file
73
terraform/iam_github_deploy.tf
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Federated"
|
||||||
|
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "token.actions.githubusercontent.com:aud"
|
||||||
|
values = ["sts.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringLike"
|
||||||
|
variable = "token.actions.githubusercontent.com:sub"
|
||||||
|
values = ["repo:${var.github_repo}:ref:refs/heads/${var.github_deploy_branch}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "github_deploy" {
|
||||||
|
name = local.deploy_role
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "GitHub Actions content-deploy role for ${var.github_repo}@${var.github_deploy_branch}"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
max_session_duration = 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy" {
|
||||||
|
statement {
|
||||||
|
sid = "OriginBucketObjects"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:GetObjectTagging",
|
||||||
|
"s3:PutObjectTagging",
|
||||||
|
]
|
||||||
|
resources = ["${aws_s3_bucket.origin.arn}/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "OriginBucketList"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:GetBucketLocation",
|
||||||
|
]
|
||||||
|
resources = [aws_s3_bucket.origin.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvalidateDistribution"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"cloudfront:CreateInvalidation",
|
||||||
|
"cloudfront:GetInvalidation",
|
||||||
|
]
|
||||||
|
resources = [aws_cloudfront_distribution.site.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "github_deploy" {
|
||||||
|
name = "seahaven-site-content-deploy"
|
||||||
|
role = aws_iam_role.github_deploy.id
|
||||||
|
policy = data.aws_iam_policy_document.github_deploy.json
|
||||||
|
}
|
||||||
12
terraform/locals.tf
Normal file
12
terraform/locals.tf
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
locals {
|
||||||
|
account_id = data.aws_caller_identity.current.account_id
|
||||||
|
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary"
|
||||||
|
bucket_name = "seahaven-site-prod"
|
||||||
|
deploy_role = "githubdeploy-seahaven-site"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
|
data "aws_iam_openid_connect_provider" "github" {
|
||||||
|
url = "https://token.actions.githubusercontent.com"
|
||||||
|
}
|
||||||
35
terraform/outputs.tf
Normal file
35
terraform/outputs.tf
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
output "origin_bucket_name" {
|
||||||
|
description = "S3 origin bucket name for content sync"
|
||||||
|
value = aws_s3_bucket.origin.bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cloudfront_distribution_id" {
|
||||||
|
description = "CloudFront distribution ID for invalidations"
|
||||||
|
value = aws_cloudfront_distribution.site.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cloudfront_domain_name" {
|
||||||
|
description = "CloudFront distribution domain (*.cloudfront.net)"
|
||||||
|
value = aws_cloudfront_distribution.site.domain_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "github_deploy_role_arn" {
|
||||||
|
description = "OIDC role ARN for GitHub Actions content deploy"
|
||||||
|
value = aws_iam_role.github_deploy.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "acm_certificate_arn" {
|
||||||
|
description = "ACM certificate ARN (us-east-1) for the apex domain"
|
||||||
|
value = aws_acm_certificate.site.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "acm_validation_records" {
|
||||||
|
description = "DNS validation CNAMEs to upsert in the mgmt seahaven.com zone"
|
||||||
|
value = [
|
||||||
|
for dvo in aws_acm_certificate.site.domain_validation_options : {
|
||||||
|
name = dvo.resource_record_name
|
||||||
|
type = dvo.resource_record_type
|
||||||
|
value = dvo.resource_record_value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
terraform/providers.tf
Normal file
11
terraform/providers.tf
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
|
||||||
|
default_tags {
|
||||||
|
tags = {
|
||||||
|
Project = "seahaven-site"
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Workspace = "seahaven-site-prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
terraform/s3.tf
Normal file
90
terraform/s3.tf
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
resource "aws_s3_bucket" "origin" {
|
||||||
|
bucket = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "origin" {
|
||||||
|
bucket = aws_s3_bucket.origin.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_ownership_controls" "origin" {
|
||||||
|
bucket = aws_s3_bucket.origin.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
object_ownership = "BucketOwnerEnforced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "origin" {
|
||||||
|
bucket = aws_s3_bucket.origin.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "origin" {
|
||||||
|
bucket = aws_s3_bucket.origin.id
|
||||||
|
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "origin_ssl_only" {
|
||||||
|
statement {
|
||||||
|
sid = "DenyInsecureTransport"
|
||||||
|
effect = "Deny"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "*"
|
||||||
|
identifiers = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:*"]
|
||||||
|
resources = [
|
||||||
|
aws_s3_bucket.origin.arn,
|
||||||
|
"${aws_s3_bucket.origin.arn}/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "Bool"
|
||||||
|
variable = "aws:SecureTransport"
|
||||||
|
values = ["false"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AllowCloudFrontOacRead"
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["cloudfront.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
"s3:GetObject",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"${aws_s3_bucket.origin.arn}/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "AWS:SourceArn"
|
||||||
|
values = [aws_cloudfront_distribution.site.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_policy" "origin" {
|
||||||
|
bucket = aws_s3_bucket.origin.id
|
||||||
|
policy = data.aws_iam_policy_document.origin_ssl_only.json
|
||||||
|
}
|
||||||
9
terraform/terraform.tfvars.example
Normal file
9
terraform/terraform.tfvars.example
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Wire these as HCP workspace Terraform variables (never commit real .tfvars).
|
||||||
|
# Secrets: N/A for this stack (Basin/reCAPTCHA are client-side third-party).
|
||||||
|
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
domain_name = "seahaven.com"
|
||||||
|
github_repo = "Sea-Haven-Industries/seahaven-site"
|
||||||
|
github_deploy_branch = "main"
|
||||||
|
# Flip to true after scripts/setup_seahaven_site_domain.sh cert issues the ACM cert.
|
||||||
|
attach_apex_alias = false
|
||||||
32
terraform/variables.tf
Normal file
32
terraform/variables.tf
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
variable "aws_region" {
|
||||||
|
type = string
|
||||||
|
description = "AWS region for regional resources (CloudFront/ACM for this stack are us-east-1)"
|
||||||
|
default = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
type = string
|
||||||
|
description = "Public apex hostname served by CloudFront (DNS alias stays OOB in mgmt Route53)"
|
||||||
|
default = "seahaven.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "github_repo" {
|
||||||
|
type = string
|
||||||
|
description = "GitHub owner/name for the content-deploy OIDC trust"
|
||||||
|
default = "Sea-Haven-Industries/seahaven-site"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "github_deploy_branch" {
|
||||||
|
type = string
|
||||||
|
description = "Git branch allowed to assume the content-deploy role"
|
||||||
|
default = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "attach_apex_alias" {
|
||||||
|
type = bool
|
||||||
|
description = <<EOT
|
||||||
|
When true, attach domain_name as a CloudFront alias using the ACM cert.
|
||||||
|
Keep false until OOB DNS validation has issued the cert (scripts/setup_seahaven_site_domain.sh cert).
|
||||||
|
EOT
|
||||||
|
default = false
|
||||||
|
}
|
||||||
18
terraform/versions.tf
Normal file
18
terraform/versions.tf
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.7.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 6.57"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud {
|
||||||
|
organization = "seahaven"
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
name = "seahaven-site-prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue