shoc-frontend-new/terraform/live/modules/environment-owned/main.tf
Adam Moussa 69c24c1c2c
feat(terraform): ship dev content CD through Terraform (SH-300) (#180)
* feat(terraform): ship dev content CD through Terraform (SH-300)

GitHub uploads immutable release prefixes; Terraform owns live publish.
Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set.

* fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
2026-09-11 13:40:14 -04:00

441 lines
11 KiB
HCL

locals {
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
github_subject_operator = var.pre_adoption_github_subject_operator
previous_origin_id = "${var.origin_id}-previous"
origin_group_id = "${var.origin_id}-group"
pointer_key = ".release/current"
pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {})
# coalesce() skips empty strings, so a null var plus a missing pointer
# would error. Empty string is the legacy root layout and must be valid.
current_label = (
var.release_version_label != null
? var.release_version_label
: try(local.pointer_body.current, "")
)
previous_label = (
var.previous_release_version_label != null
? var.previous_release_version_label
: try(local.pointer_body.previous, "")
)
current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}"
previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}"
spa_rewrite_code = join("\n", [
"function handler(event) {",
" var request = event.request;",
" var uri = request.uri;",
" // No file extension after the last slash -> a client-side route.",
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
" request.uri = '/index.html';",
" }",
" return request;",
"}",
])
}
data "aws_s3_objects" "release_prefix" {
bucket = aws_s3_bucket.site.bucket
prefix = ".release/"
}
data "aws_s3_object" "release_pointer" {
count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
}
data "aws_iam_policy_document" "site_bucket" {
dynamic "statement" {
for_each = var.adoption_complete ? [] : [1]
content {
effect = "Allow"
principals {
type = "AWS"
identifiers = [var.bucket_auto_delete_helper_role_arn]
}
actions = [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
]
resources = [
local.bucket_arn,
"${local.bucket_arn}/*",
]
}
}
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${local.bucket_arn}/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [local.distribution_arn]
}
}
statement {
effect = "Deny"
principals {
type = "AWS"
identifiers = ["*"]
}
actions = ["s3:*"]
resources = [
local.bucket_arn,
"${local.bucket_arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [var.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = local.github_subject_operator
variable = "token.actions.githubusercontent.com:sub"
values = [var.github_subject]
}
}
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "ListReleasePrefixes"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = [local.bucket_arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = [
"releases/",
"releases/*",
]
}
}
statement {
sid = "PublishReleasePrefix"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
]
resources = ["${local.bucket_arn}/releases/*"]
}
statement {
sid = "ReadReleasePointer"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${local.bucket_arn}/${local.pointer_key}"]
}
statement {
sid = "ReadDistribution"
effect = "Allow"
actions = [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
]
resources = [local.distribution_arn]
}
}
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
force_destroy = false
tags = local.bucket_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_ownership_controls" "site" {
bucket = aws_s3_bucket.site.id
rule {
object_ownership = "BucketOwnerEnforced"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
bucket_key_enabled = false
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site_bucket.json
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_object" "release_pointer" {
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
content_type = "application/json"
content = jsonencode({
current = local.current_label
previous = local.previous_label
})
lifecycle {
prevent_destroy = true
}
}
resource "aws_cloudfront_origin_access_control" "site" {
name = var.origin_access_control_name
description = var.origin_access_control_description
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
lifecycle {
prevent_destroy = true
}
}
resource "aws_cloudfront_function" "spa_rewrite" {
name = var.function_name
runtime = "cloudfront-js-1.0"
comment = "SPA routing: rewrite extensionless paths to /index.html"
publish = true
code = local.spa_rewrite_code
tags = local.resource_tags
lifecycle {
prevent_destroy = true
ignore_changes = [publish]
}
}
resource "aws_cloudfront_distribution" "site" {
aliases = [var.domain_name]
comment = "SeaHaven SHOC frontend (${var.environment})"
default_root_object = "index.html"
enabled = true
http_version = "http2and3"
is_ipv6_enabled = true
price_class = "PriceClass_100"
tags = local.resource_tags
origin {
connection_attempts = 3
connection_timeout = 10
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = var.origin_id
origin_path = local.current_origin_path
}
origin {
connection_attempts = 3
connection_timeout = 10
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = local.previous_origin_id
origin_path = local.previous_origin_path
}
origin_group {
origin_id = local.origin_group_id
failover_criteria {
status_codes = [403, 404]
}
member {
origin_id = var.origin_id
}
member {
origin_id = local.previous_origin_id
}
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cache_policy_id = var.cache_policy_id
cached_methods = ["GET", "HEAD"]
compress = true
target_origin_id = local.origin_group_id
viewer_protocol_policy = "redirect-to-https"
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.spa_rewrite.arn
}
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = var.certificate_arn
minimum_protocol_version = "TLSv1.2_2021"
ssl_support_method = "sni-only"
}
lifecycle {
prevent_destroy = true
action_trigger {
events = [after_update]
actions = [action.aws_cloudfront_create_invalidation.release]
}
}
}
action "aws_cloudfront_create_invalidation" "release" {
config {
distribution_id = aws_cloudfront_distribution.site.id
paths = ["/*"]
}
}
resource "aws_route53_record" "site_a" {
zone_id = var.hosted_zone_id
name = var.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_route53_record" "site_aaaa" {
zone_id = var.hosted_zone_id
name = var.domain_name
type = "AAAA"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role" "github_deploy" {
name = var.deploy_role_name
path = "/"
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600
permissions_boundary = var.deploy_permissions_boundary_arn
tags = local.deploy_role_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = var.deploy_inline_policy_name
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
lifecycle {
prevent_destroy = true
}
}