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 } }