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 ssm_prefix = "/shoc-frontend-new/${var.environment}" github_subject = "repo:${var.github_repo}:environment:${var.environment}" 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_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 { sid = "GithubDeployOidc" 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 = "StringEquals" variable = "token.actions.githubusercontent.com:sub" values = [local.github_subject] } # StringLike: a release-triggered job loads the workflow file from the tag, # so job_workflow_ref ends in @refs/tags/vX.Y.Z-staging there and # @refs/heads/main on push and workflow_dispatch. The environment claim in # sub is the gate. condition { test = "StringLike" variable = "token.actions.githubusercontent.com:job_workflow_ref" values = [ "${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/main", "${var.github_repo}/.github/workflows/deploy-web.yaml@refs/tags/v*", ] } } } data "aws_iam_policy_document" "github_deploy" { statement { sid = "ListWebBucket" effect = "Allow" actions = [ "s3:GetBucketLocation", "s3:ListBucket", ] resources = [local.bucket_arn] } statement { sid = "SyncWebBucket" effect = "Allow" actions = [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", ] resources = ["${local.bucket_arn}/*"] } statement { sid = "InvalidateDistribution" effect = "Allow" actions = [ "cloudfront:CreateInvalidation", "cloudfront:GetInvalidation", "cloudfront:GetDistribution", ] resources = [local.distribution_arn] } statement { sid = "DeployParams" effect = "Allow" actions = [ "ssm:GetParameter", ] resources = [ aws_ssm_parameter.deploy_bucket.arn, aws_ssm_parameter.deploy_distribution_id.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 } } # Pointer leftover from Terraform-promoted content CD. Forgotten, not destroyed. # deploy-web.yaml syncs dist/ to the bucket root with --delete. removed { from = aws_s3_object.release_pointer lifecycle { destroy = false } } 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 = "" } default_cache_behavior { allowed_methods = ["GET", "HEAD", "OPTIONS"] cache_policy_id = var.cache_policy_id cached_methods = ["GET", "HEAD"] compress = true target_origin_id = var.origin_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 } } 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 SPA deploy role for ${var.github_repo} Environment ${var.environment}" 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 # SCP ProtectDeploymentPrincipalLifecycle denies UpdateRoleDescription on # githubdeploy-* for HCP apply roles. ignore_changes = [description] } } 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 } } resource "aws_ssm_parameter" "deploy_bucket" { name = "${local.ssm_prefix}/deploy/bucket" type = "String" data_type = "text" tier = "Standard" value = aws_s3_bucket.site.id description = "SPA origin bucket; deploy-web syncs dist/ to the bucket root" tags = local.resource_tags } resource "aws_ssm_parameter" "deploy_distribution_id" { name = "${local.ssm_prefix}/deploy/distribution-id" type = "String" data_type = "text" tier = "Standard" value = aws_cloudfront_distribution.site.id description = "CloudFront distribution ID; deploy-web invalidates /* after sync" tags = local.resource_tags }