From d144c0383f5545880ead618552eef14cad38db5f Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 1 Jul 2025 14:02:04 -0400 Subject: [PATCH 1/7] feat: Added infrastructure for static documentation hosting. --- .../development/docs/.terraform.lock.hcl | 20 +++ tofu/config/development/docs/main.tf | 28 ++++ tofu/config/development/docs/outputs.tf | 3 + tofu/config/development/docs/providers.tf | 12 ++ tofu/config/development/docs/variables.tf | 0 tofu/config/development/docs/versions.tf | 10 ++ tofu/modules/docs/alb.tf | 140 ++++++++++++++++++ tofu/modules/docs/data.tf | 20 +++ tofu/modules/docs/files/robots.txt | 2 + tofu/modules/docs/local.tf | 12 ++ tofu/modules/docs/main.tf | 50 +++++++ tofu/modules/docs/outputs.tf | 3 + .../docs/templates/bucket-policy.yaml.tftpl | 24 +++ .../docs/templates/key-policy.yaml.tftpl | 25 ++++ tofu/modules/docs/variables.tf | 43 ++++++ tofu/modules/docs/versions.tf | 10 ++ 16 files changed, 402 insertions(+) create mode 100644 tofu/config/development/docs/.terraform.lock.hcl create mode 100644 tofu/config/development/docs/main.tf create mode 100644 tofu/config/development/docs/outputs.tf create mode 100644 tofu/config/development/docs/providers.tf create mode 100644 tofu/config/development/docs/variables.tf create mode 100644 tofu/config/development/docs/versions.tf create mode 100644 tofu/modules/docs/alb.tf create mode 100644 tofu/modules/docs/data.tf create mode 100644 tofu/modules/docs/files/robots.txt create mode 100644 tofu/modules/docs/local.tf create mode 100644 tofu/modules/docs/main.tf create mode 100644 tofu/modules/docs/outputs.tf create mode 100644 tofu/modules/docs/templates/bucket-policy.yaml.tftpl create mode 100644 tofu/modules/docs/templates/key-policy.yaml.tftpl create mode 100644 tofu/modules/docs/variables.tf create mode 100644 tofu/modules/docs/versions.tf diff --git a/tofu/config/development/docs/.terraform.lock.hcl b/tofu/config/development/docs/.terraform.lock.hcl new file mode 100644 index 0000000..aa818ac --- /dev/null +++ b/tofu/config/development/docs/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.100.0" + constraints = ">= 4.15.1, ~> 5.44" + hashes = [ + "h1:BrNG7eFOdRrRRbHdvrTjMJ8X8Oh/tiegURiKf7J2db8=", + "zh:1a41f3ee26720fee7a9a0a361890632a1701b5dc1cf5355dc651ddbe115682ff", + "zh:30457f36690c19307921885cc5e72b9dbeba369445815903acd5c39ac0e41e7a", + "zh:42c22674d5f23f6309eaf3ac3a4f1f8b66b566c1efe1dcb0dd2fb30c17ce1f78", + "zh:4cc271c795ff8ce6479ec2d11a8ba65a0a9ed6331def6693f4b9dccb6e662838", + "zh:60932aa376bb8c87cd1971240063d9d38ba6a55502c867fdbb9f5361dc93d003", + "zh:864e42784bde77b18393ebfcc0104cea9123da5f4392e8a059789e296952eefa", + "zh:9750423138bb01ecaa5cec1a6691664f7783d301fb1628d3b64a231b6b564e0e", + "zh:e5d30c4dec271ef9d6fe09f48237ec6cfea1036848f835b4e47f274b48bda5a7", + "zh:e62bd314ae97b43d782e0841b13e68a3f8ec85cc762004f973ce5ce7b6cdbfd0", + "zh:ea851a3c072528a4445ac6236ba2ce58ffc99ec466019b0bd0e4adde63a248e4", + ] +} diff --git a/tofu/config/development/docs/main.tf b/tofu/config/development/docs/main.tf new file mode 100644 index 0000000..a75c4e8 --- /dev/null +++ b/tofu/config/development/docs/main.tf @@ -0,0 +1,28 @@ +terraform { + backend "s3" { + bucket = "shared-services-development-tfstate" + key = "docs.tfstate" + region = "us-east-1" + dynamodb_table = "development.tfstate" + } +} + +module "docs" { + source = "../../../modules/docs" + + environment = "development" + bucket_name = "docs.dev.services.cfa.codes" + force_delete = true + domain = "dev.services.cfa.codes" + subdomain = "docs" + + # Use the same VPC we use for shared hosting. + # TODO: Use data resources to look this up. + logging_bucket = "shared-services-development-logs" + vpc_id = "vpc-024d66fcc4f521d0a" + public_subnets = [ + "subnet-04a634af483d94adb", + "subnet-0ff03c3903c4c785c", + "subnet-037226b6f514e6dd1" + ] +} diff --git a/tofu/config/development/docs/outputs.tf b/tofu/config/development/docs/outputs.tf new file mode 100644 index 0000000..ca056fd --- /dev/null +++ b/tofu/config/development/docs/outputs.tf @@ -0,0 +1,3 @@ +output "endpoint_url" { + value = module.docs.endpoint_url +} diff --git a/tofu/config/development/docs/providers.tf b/tofu/config/development/docs/providers.tf new file mode 100644 index 0000000..1ad66f4 --- /dev/null +++ b/tofu/config/development/docs/providers.tf @@ -0,0 +1,12 @@ +provider "aws" { + region = "us-east-1" + + default_tags { + tags = { + application = "cfa-documentation-development" + environment = "development" + program = "engineering" + project = "cfa-documentation" + } + } +} diff --git a/tofu/config/development/docs/variables.tf b/tofu/config/development/docs/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/tofu/config/development/docs/versions.tf b/tofu/config/development/docs/versions.tf new file mode 100644 index 0000000..d31d078 --- /dev/null +++ b/tofu/config/development/docs/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.44" + } + } +} diff --git a/tofu/modules/docs/alb.tf b/tofu/modules/docs/alb.tf new file mode 100644 index 0000000..047f320 --- /dev/null +++ b/tofu/modules/docs/alb.tf @@ -0,0 +1,140 @@ +module "endpoint_security_group" { + source = "terraform-aws-modules/security-group/aws" + version = "~> 5.3" + + name = "${local.prefix}-endpoint" + vpc_id = var.vpc_id + + # Ingress for HTTP + ingress_cidr_blocks = ["0.0.0.0/0"] + ingress_rules = ["http-80-tcp", "https-443-tcp"] + + # Allow egress to the S3 endpoints. + egress_cidr_blocks = [for ip in local.s3_ips : "${ip}/32"] + egress_rules = ["https-443-tcp"] + + tags = local.tags +} + +module "alb" { + source = "terraform-aws-modules/alb/aws" + version = "~> 9.17" + + name = local.prefix + enable_deletion_protection = !var.force_delete + load_balancer_type = "application" + security_groups = [module.endpoint_security_group.security_group_id] + subnets = var.public_subnets + vpc_id = var.vpc_id + internal = false + + access_logs = { + bucket = var.logging_bucket + enabled = true + } + + connection_logs = { + bucket = var.logging_bucket + enabled = true + } + + # TODO: Support IPv6 and/or dualstack. + ip_address_type = "ipv4" + + listeners = { + http = { + port = 80 + protocol = "HTTP" + redirect = { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + https = { + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" + certificate_arn = aws_acm_certificate.endpoint.arn + forward = { + target_group_key = "endpoint" + } + } + } + + target_groups = { + endpoint = { + name = local.prefix + protocol = "HTTPS" + target_type = "ip" + port = 443 + + # We have multiple IPs to attach, so we'll create the attachments + # ourselves. + create_attachment = false + + health_check = { + healthy_threshold = 5 + protocol = "HTTPS" + unhealthy_threshold = 2 + success_codes = "200,307,405" + } + } + } + + additional_target_group_attachments = { for ip in local.s3_ips : ip => { + target_group_key = "endpoint" + target_id = ip + port = 443 + } } + + tags = local.tags +} + +resource "aws_acm_certificate" "endpoint" { + domain_name = local.fqdn + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = local.tags +} + +resource "aws_route53_record" "endpoint" { + name = local.fqdn + type = "A" + zone_id = data.aws_route53_zone.domain.zone_id + + alias { + name = module.alb.dns_name + zone_id = module.alb.zone_id + evaluate_target_health = true + } +} + +resource "aws_route53_record" "endpoint_validation" { + for_each = { + for dvo in aws_acm_certificate.endpoint.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + ttl = 60 + type = each.value.type + zone_id = data.aws_route53_zone.domain.zone_id +} + +resource "aws_acm_certificate_validation" "endpoint" { + certificate_arn = aws_acm_certificate.endpoint.arn + validation_record_fqdns = [ + for record in aws_route53_record.endpoint_validation : record.fqdn + ] +} diff --git a/tofu/modules/docs/data.tf b/tofu/modules/docs/data.tf new file mode 100644 index 0000000..406f080 --- /dev/null +++ b/tofu/modules/docs/data.tf @@ -0,0 +1,20 @@ +data "aws_caller_identity" "identity" {} + +data "aws_partition" "current" {} + +data "aws_region" "current" {} + +data "aws_route53_zone" "domain" { + name = var.domain +} + +data "aws_vpc_endpoint" "s3" { + vpc_id = var.vpc_id + service_name = "com.amazonaws.${data.aws_region.current.name}.s3" +} + +data "aws_network_interface" "s3" { + for_each = toset(data.aws_vpc_endpoint.s3.network_interface_ids) + + id = each.value +} diff --git a/tofu/modules/docs/files/robots.txt b/tofu/modules/docs/files/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/tofu/modules/docs/files/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/tofu/modules/docs/local.tf b/tofu/modules/docs/local.tf new file mode 100644 index 0000000..76c560c --- /dev/null +++ b/tofu/modules/docs/local.tf @@ -0,0 +1,12 @@ +locals { + fqdn = "${var.subdomain}.${var.domain}" + prefix = "cfa-documentation-${var.environment}" + s3_ips = [for ni in data.aws_network_interface.s3 : ni.private_ip] + tags_base = { + application = local.prefix + program = "engineering" + project = "cfa-documenation" + environment = var.environment + } + tags = merge(local.tags_base, resource.aws_servicecatalogappregistry_application.docs.application_tag) +} diff --git a/tofu/modules/docs/main.tf b/tofu/modules/docs/main.tf new file mode 100644 index 0000000..66aec59 --- /dev/null +++ b/tofu/modules/docs/main.tf @@ -0,0 +1,50 @@ +resource "aws_servicecatalogappregistry_application" "docs" { + name = local.prefix + description = "Static documentation hosting for Code for America." + + tags = local.tags_base +} + +resource "aws_kms_key" "docs" { + description = "Encryption key for static documentation hosting" + enable_key_rotation = true + policy = jsonencode(yamldecode(templatefile("${path.module}/templates/key-policy.yaml.tftpl", { + account_id : data.aws_caller_identity.identity.account_id, + partition : data.aws_partition.current.partition, + bucket_name : var.bucket_name, + }))) + + tags = resource.aws_servicecatalogappregistry_application.docs.application_tag +} + +resource "aws_kms_alias" "docs" { + name = "alias/cfa-docummentation/${var.environment}" + target_key_id = aws_kms_key.docs.id +} + +module "bucket" { + source = "boldlink/s3/aws" + version = "~> 2.5.0" + + bucket = var.bucket_name + sse_sse_algorithm = "aws:kms" + sse_bucket_key_enabled = true + sse_kms_master_key_arn = aws_kms_key.docs.arn + force_destroy = var.force_delete + + versioning_status = "Enabled" + + bucket_policy = jsonencode(yamldecode(templatefile("${path.module}/templates/bucket-policy.yaml.tftpl", { + bucket_arn : module.bucket.arn, + vpc_endpoint_id : data.aws_vpc_endpoint.s3.id, + }))) + + tags = local.tags +} + +resource "aws_s3_object" "robots" { + bucket = module.bucket.bucket + key = "robots.txt" + source = "${path.module}/files/robots.txt" + force_destroy = var.force_delete +} diff --git a/tofu/modules/docs/outputs.tf b/tofu/modules/docs/outputs.tf new file mode 100644 index 0000000..b087e01 --- /dev/null +++ b/tofu/modules/docs/outputs.tf @@ -0,0 +1,3 @@ +output "endpoint_url" { + value = aws_route53_record.endpoint.fqdn +} diff --git a/tofu/modules/docs/templates/bucket-policy.yaml.tftpl b/tofu/modules/docs/templates/bucket-policy.yaml.tftpl new file mode 100644 index 0000000..bc0c56e --- /dev/null +++ b/tofu/modules/docs/templates/bucket-policy.yaml.tftpl @@ -0,0 +1,24 @@ +Version: '2012-10-17' +Statement: +- Sid: AllowSSLRequestsOnly + Effect: Deny + Principal: "*" + Action: + - s3:* + Resource: + - "${bucket_arn}" + - "${bucket_arn}/*" + Condition: + Bool: + aws:SecureTransport: false +- Sid: Allow VPC Endpoint access + Effect: Allow + Principal: "*" + Action: + - s3:* + Resource: + - "${bucket_arn}" + - "${bucket_arn}/*" + Condition: + StringEquals: + aws:SourceVpce: "${vpc_endpoint_id}" diff --git a/tofu/modules/docs/templates/key-policy.yaml.tftpl b/tofu/modules/docs/templates/key-policy.yaml.tftpl new file mode 100644 index 0000000..5dd94fe --- /dev/null +++ b/tofu/modules/docs/templates/key-policy.yaml.tftpl @@ -0,0 +1,25 @@ +Version: "2012-10-17" +Id: key-policy-${bucket_name} +Statement: +- Sid: Enable IAM User Permissions + Effect: Allow + Principal: + AWS: arn:${partition}:iam::${account_id}:root + Action: kms:* + Resource: "*" +- Sid: Allow S3 to encrypt and decrypt objects + Effect: Allow + Principal: + AWS: "*" + Action: + - kms:Encrypt + - kms:Decrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringLike: + kms:CallerAccount: "${account_id}" + kms:EncryptionContext:aws:s3:arn: + - arn:${partition}:s3:::${bucket_name} + - arn:${partition}:s3:::${bucket_name}/* diff --git a/tofu/modules/docs/variables.tf b/tofu/modules/docs/variables.tf new file mode 100644 index 0000000..c6c15d4 --- /dev/null +++ b/tofu/modules/docs/variables.tf @@ -0,0 +1,43 @@ +variable "bucket_name" { + description = "The name of the S3 bucket for documentation hosting." + type = string + default = "docs.dev.cfa.codes" +} + +variable "domain" { + description = "The domain for the documentation hosting." + type = string + default = "dev.cfa.codes" +} + +variable "environment" { + description = "The environment for documentation hosting." + type = string +} + +variable "force_delete" { + description = "Whether to allow resources to be deleted." + type = bool + default = false +} + +variable "logging_bucket" { + description = "The S3 bucket used for logging." + type = string +} + +variable "public_subnets" { + description = "List of public subnets for the application." + type = list(string) +} + +variable "subdomain" { + description = "The subdomain for the documentation hosting." + type = string + default = "docs" +} + +variable "vpc_id" { + description = "The VPC where the documentation hosting resources will be deployed." + type = string +} diff --git a/tofu/modules/docs/versions.tf b/tofu/modules/docs/versions.tf new file mode 100644 index 0000000..4a62819 --- /dev/null +++ b/tofu/modules/docs/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + aws = { + version = "~> 5.93" + source = "hashicorp/aws" + } + } +} From cbaee09f2a2160c5b26a44678dd23698ccf6db9f Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 1 Jul 2025 14:31:20 -0400 Subject: [PATCH 2/7] ci: Updated documentation deployment job to use the new bucket. --- .github/workflows/docs.yaml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index d0efd88..82f5da0 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -14,10 +14,15 @@ on: permissions: contents: read +env: + AWS_REGION: ${{ env.AWS_REGION || 'us-east-1' }} + BUCKET_NAME: ${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }} + PREFIX: ${{ env.PREFIX || 'shared-services' }} + jobs: deploy: name: Deploy Documentation - environment: 'docs-dev' + environment: development runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -26,7 +31,7 @@ jobs: with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 + aws-region: ${{ env.AWS_REGION }} - uses: actions/setup-python@v5 with: python-version: 3.x @@ -39,4 +44,4 @@ jobs: mkdocs-material- - run: pip install mkdocs-material markdown-callouts mdx_truly_sane_lists mkdocs-nav-weight pymdown-extensions - run: mkdocs build - - run: aws s3 sync ./site "s3://${{ env.BUCKET_NAME || 'dev.docs.cfa.codes' }}/${{ env.PREFIX || 'shared-services' }}" + - run: aws s3 sync ./site "s3://$BUCKET_NAME/$PREFIX" From 2c6493d7dcf6c758c2f7160a113b129a0ba3d551 Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 1 Jul 2025 14:33:22 -0400 Subject: [PATCH 3/7] ci: Updated documentation deployment job to use an input for environment. --- .github/workflows/docs.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 82f5da0..ca7ef15 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -2,6 +2,12 @@ name: Deploy documentation on: workflow_dispatch: + inputs: + environment: + description: Environment to deploy to. + default: development + required: true + type: environment push: paths: # Only trigger on changes to documentation files. @@ -21,8 +27,8 @@ env: jobs: deploy: - name: Deploy Documentation - environment: development + name: Deploy Documentation to ${{ inputs.environment }} + environment: ${{ inputs.environment }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 5e0cad597b166a0a10ede724c87c095710f0d990 Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 1 Jul 2025 14:34:25 -0400 Subject: [PATCH 4/7] ci: Updated documentation deployment job to use an input for environment. --- .github/workflows/docs.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index ca7ef15..0176cf2 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -20,16 +20,20 @@ on: permissions: contents: read -env: - AWS_REGION: ${{ env.AWS_REGION || 'us-east-1' }} - BUCKET_NAME: ${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }} - PREFIX: ${{ env.PREFIX || 'shared-services' }} +#env: +# AWS_REGION: ${{ env.AWS_REGION || 'us-east-1' }} +# BUCKET_NAME: ${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }} +# PREFIX: ${{ env.PREFIX || 'shared-services' }} jobs: deploy: name: Deploy Documentation to ${{ inputs.environment }} environment: ${{ inputs.environment }} runs-on: ubuntu-latest + env: + AWS_REGION: ${{ env.AWS_REGION || 'us-east-1' }} + BUCKET_NAME: ${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }} + PREFIX: ${{ env.PREFIX || 'shared-services' }} steps: - uses: actions/checkout@v4 - name: Set up AWS credentials From 8cbbe6f00b60d00bcee9c3cc753b475a48a51d5b Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 1 Jul 2025 14:36:47 -0400 Subject: [PATCH 5/7] ci: Updated documentation deployment job to use an input for environment. --- .github/workflows/docs.yaml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 0176cf2..9d9e399 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -20,20 +20,11 @@ on: permissions: contents: read -#env: -# AWS_REGION: ${{ env.AWS_REGION || 'us-east-1' }} -# BUCKET_NAME: ${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }} -# PREFIX: ${{ env.PREFIX || 'shared-services' }} - jobs: deploy: name: Deploy Documentation to ${{ inputs.environment }} environment: ${{ inputs.environment }} runs-on: ubuntu-latest - env: - AWS_REGION: ${{ env.AWS_REGION || 'us-east-1' }} - BUCKET_NAME: ${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }} - PREFIX: ${{ env.PREFIX || 'shared-services' }} steps: - uses: actions/checkout@v4 - name: Set up AWS credentials @@ -41,7 +32,7 @@ jobs: with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ env.AWS_REGION }} + aws-region: ${{ env.AWS_REGION || 'us-east-1' }} - uses: actions/setup-python@v5 with: python-version: 3.x @@ -54,4 +45,4 @@ jobs: mkdocs-material- - run: pip install mkdocs-material markdown-callouts mdx_truly_sane_lists mkdocs-nav-weight pymdown-extensions - run: mkdocs build - - run: aws s3 sync ./site "s3://$BUCKET_NAME/$PREFIX" + - run: aws s3 sync ./site "s3://${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }}/${{ env.PREFIX || 'shared-services' }}" From cc5432cbf635f100c13c825ca6c8794ea548b8b3 Mon Sep 17 00:00:00 2001 From: James Armes Date: Wed, 2 Jul 2025 14:11:45 -0400 Subject: [PATCH 6/7] fix: Use CloudFront instead of an ALB to serve documentation. --- tofu/config/development/docs/outputs.tf | 3 +- tofu/modules/docs/alb.tf | 140 ------------------ tofu/modules/docs/data.tf | 4 + tofu/modules/docs/endpoint.tf | 125 ++++++++++++++++ tofu/modules/docs/files/index.html | 9 ++ tofu/modules/docs/files/rewrite-function.js | 16 ++ tofu/modules/docs/main.tf | 21 ++- tofu/modules/docs/outputs.tf | 3 +- .../docs/templates/bucket-policy.yaml.tftpl | 11 ++ .../docs/templates/key-policy.yaml.tftpl | 12 ++ 10 files changed, 196 insertions(+), 148 deletions(-) delete mode 100644 tofu/modules/docs/alb.tf create mode 100644 tofu/modules/docs/endpoint.tf create mode 100644 tofu/modules/docs/files/index.html create mode 100644 tofu/modules/docs/files/rewrite-function.js diff --git a/tofu/config/development/docs/outputs.tf b/tofu/config/development/docs/outputs.tf index ca056fd..83f086e 100644 --- a/tofu/config/development/docs/outputs.tf +++ b/tofu/config/development/docs/outputs.tf @@ -1,3 +1,4 @@ output "endpoint_url" { - value = module.docs.endpoint_url + description = "The URL of the documentation endpoint." + value = module.docs.endpoint_url } diff --git a/tofu/modules/docs/alb.tf b/tofu/modules/docs/alb.tf deleted file mode 100644 index 047f320..0000000 --- a/tofu/modules/docs/alb.tf +++ /dev/null @@ -1,140 +0,0 @@ -module "endpoint_security_group" { - source = "terraform-aws-modules/security-group/aws" - version = "~> 5.3" - - name = "${local.prefix}-endpoint" - vpc_id = var.vpc_id - - # Ingress for HTTP - ingress_cidr_blocks = ["0.0.0.0/0"] - ingress_rules = ["http-80-tcp", "https-443-tcp"] - - # Allow egress to the S3 endpoints. - egress_cidr_blocks = [for ip in local.s3_ips : "${ip}/32"] - egress_rules = ["https-443-tcp"] - - tags = local.tags -} - -module "alb" { - source = "terraform-aws-modules/alb/aws" - version = "~> 9.17" - - name = local.prefix - enable_deletion_protection = !var.force_delete - load_balancer_type = "application" - security_groups = [module.endpoint_security_group.security_group_id] - subnets = var.public_subnets - vpc_id = var.vpc_id - internal = false - - access_logs = { - bucket = var.logging_bucket - enabled = true - } - - connection_logs = { - bucket = var.logging_bucket - enabled = true - } - - # TODO: Support IPv6 and/or dualstack. - ip_address_type = "ipv4" - - listeners = { - http = { - port = 80 - protocol = "HTTP" - redirect = { - port = "443" - protocol = "HTTPS" - status_code = "HTTP_301" - } - } - - https = { - port = 443 - protocol = "HTTPS" - ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" - certificate_arn = aws_acm_certificate.endpoint.arn - forward = { - target_group_key = "endpoint" - } - } - } - - target_groups = { - endpoint = { - name = local.prefix - protocol = "HTTPS" - target_type = "ip" - port = 443 - - # We have multiple IPs to attach, so we'll create the attachments - # ourselves. - create_attachment = false - - health_check = { - healthy_threshold = 5 - protocol = "HTTPS" - unhealthy_threshold = 2 - success_codes = "200,307,405" - } - } - } - - additional_target_group_attachments = { for ip in local.s3_ips : ip => { - target_group_key = "endpoint" - target_id = ip - port = 443 - } } - - tags = local.tags -} - -resource "aws_acm_certificate" "endpoint" { - domain_name = local.fqdn - validation_method = "DNS" - - lifecycle { - create_before_destroy = true - } - - tags = local.tags -} - -resource "aws_route53_record" "endpoint" { - name = local.fqdn - type = "A" - zone_id = data.aws_route53_zone.domain.zone_id - - alias { - name = module.alb.dns_name - zone_id = module.alb.zone_id - evaluate_target_health = true - } -} - -resource "aws_route53_record" "endpoint_validation" { - for_each = { - for dvo in aws_acm_certificate.endpoint.domain_validation_options : dvo.domain_name => { - name = dvo.resource_record_name - record = dvo.resource_record_value - type = dvo.resource_record_type - } - } - - allow_overwrite = true - name = each.value.name - records = [each.value.record] - ttl = 60 - type = each.value.type - zone_id = data.aws_route53_zone.domain.zone_id -} - -resource "aws_acm_certificate_validation" "endpoint" { - certificate_arn = aws_acm_certificate.endpoint.arn - validation_record_fqdns = [ - for record in aws_route53_record.endpoint_validation : record.fqdn - ] -} diff --git a/tofu/modules/docs/data.tf b/tofu/modules/docs/data.tf index 406f080..f1c3027 100644 --- a/tofu/modules/docs/data.tf +++ b/tofu/modules/docs/data.tf @@ -18,3 +18,7 @@ data "aws_network_interface" "s3" { id = each.value } + +data "aws_cloudfront_cache_policy" "endpoint" { + name = "Managed-CachingOptimized" +} diff --git a/tofu/modules/docs/endpoint.tf b/tofu/modules/docs/endpoint.tf new file mode 100644 index 0000000..ac8e138 --- /dev/null +++ b/tofu/modules/docs/endpoint.tf @@ -0,0 +1,125 @@ +resource "aws_cloudfront_origin_access_control" "endpoint" { + name = "${local.prefix}-endpoint" + description = "Authorize CloudFront to serve content from the documentation S3 bucket." + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_cloudfront_function" "endpoint_rewrite" { + name = "cfa-documentation-${var.environment}-rewrite" + comment = "Rewrite requests to direct to the index.html file in the S3 bucket." + runtime = "cloudfront-js-2.0" + publish = true + + code = file("${path.module}/files/rewrite-function.js") + + lifecycle { + create_before_destroy = true + } +} + +# TODO: Request OIDC authentication for the CloudFront distribution. +resource "aws_cloudfront_distribution" "endpoint" { + enabled = true + comment = "Serve static documentation from S3." + is_ipv6_enabled = true + aliases = [local.fqdn] + price_class = "PriceClass_100" + default_root_object = "index.html" + + origin { + domain_name = module.bucket.bucket_regional_domain_name + origin_id = local.prefix + origin_access_control_id = aws_cloudfront_origin_access_control.endpoint.id + } + + logging_config { + include_cookies = false + bucket = "${var.logging_bucket}.s3.amazonaws.com" + prefix = "cloudfront/${local.fqdn}" + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = local.prefix + compress = true + default_ttl = 0 + max_ttl = 0 + min_ttl = 0 + + cache_policy_id = data.aws_cloudfront_cache_policy.endpoint.id + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.endpoint_rewrite.arn + } + + viewer_protocol_policy = "redirect-to-https" + } + + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } + + viewer_certificate { + acm_certificate_arn = aws_acm_certificate.endpoint.arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + + tags = local.tags +} + +resource "aws_acm_certificate" "endpoint" { + domain_name = local.fqdn + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = local.tags +} + +resource "aws_route53_record" "endpoint_validation" { + for_each = { + for dvo in aws_acm_certificate.endpoint.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + ttl = 60 + type = each.value.type + zone_id = data.aws_route53_zone.domain.zone_id +} + +resource "aws_acm_certificate_validation" "endpoint" { + certificate_arn = aws_acm_certificate.endpoint.arn + validation_record_fqdns = [ + for record in aws_route53_record.endpoint_validation : record.fqdn + ] +} + +resource "aws_route53_record" "endpoint" { + for_each = toset(["A", "AAAA"]) + + name = local.fqdn + type = each.value + zone_id = data.aws_route53_zone.domain.zone_id + + alias { + # CloudFront doesn't provide a health check. + evaluate_target_health = false + name = aws_cloudfront_distribution.endpoint.domain_name + zone_id = aws_cloudfront_distribution.endpoint.hosted_zone_id + } +} diff --git a/tofu/modules/docs/files/index.html b/tofu/modules/docs/files/index.html new file mode 100644 index 0000000..6db8915 --- /dev/null +++ b/tofu/modules/docs/files/index.html @@ -0,0 +1,9 @@ + + + + Code for America Engineering Documentation (Development) + + +

Code for America Engineering Documentation (Development)

+ + diff --git a/tofu/modules/docs/files/rewrite-function.js b/tofu/modules/docs/files/rewrite-function.js new file mode 100644 index 0000000..353b1b0 --- /dev/null +++ b/tofu/modules/docs/files/rewrite-function.js @@ -0,0 +1,16 @@ +function handler(event) { + var request = event.request; + var uri = request.uri; + + // If the request is being made to a directory (e.g. / or /docs), we want to + // append "index.html" so that S3 serves the proper object. If the path + // doesn't contain a file extension, we assume it's a directory as well. + if (uri.endsWith('/')) { + request.uri += 'index.html'; + } + else if (!uri.includes('.')) { + request.uri += '/index.html' + } + + return request; +} diff --git a/tofu/modules/docs/main.tf b/tofu/modules/docs/main.tf index 66aec59..0ec3333 100644 --- a/tofu/modules/docs/main.tf +++ b/tofu/modules/docs/main.tf @@ -6,12 +6,13 @@ resource "aws_servicecatalogappregistry_application" "docs" { } resource "aws_kms_key" "docs" { - description = "Encryption key for static documentation hosting" + description = "Encryption key for static documentation hosting" enable_key_rotation = true policy = jsonencode(yamldecode(templatefile("${path.module}/templates/key-policy.yaml.tftpl", { account_id : data.aws_caller_identity.identity.account_id, partition : data.aws_partition.current.partition, bucket_name : var.bucket_name, + cloudfront_distribution_arn : aws_cloudfront_distribution.endpoint.arn, }))) tags = resource.aws_servicecatalogappregistry_application.docs.application_tag @@ -30,21 +31,29 @@ module "bucket" { sse_sse_algorithm = "aws:kms" sse_bucket_key_enabled = true sse_kms_master_key_arn = aws_kms_key.docs.arn - force_destroy = var.force_delete - versioning_status = "Enabled" + force_destroy = var.force_delete bucket_policy = jsonencode(yamldecode(templatefile("${path.module}/templates/bucket-policy.yaml.tftpl", { bucket_arn : module.bucket.arn, vpc_endpoint_id : data.aws_vpc_endpoint.s3.id, + cloudfront_distribution_arn : aws_cloudfront_distribution.endpoint.arn, }))) tags = local.tags } resource "aws_s3_object" "robots" { - bucket = module.bucket.bucket - key = "robots.txt" - source = "${path.module}/files/robots.txt" + bucket = module.bucket.bucket + key = "robots.txt" + source = "${path.module}/files/robots.txt" + force_destroy = var.force_delete +} + +resource "aws_s3_object" "index" { + bucket = module.bucket.bucket + key = "index.html" + source = "${path.module}/files/index.html" + content_type = "text/html" force_destroy = var.force_delete } diff --git a/tofu/modules/docs/outputs.tf b/tofu/modules/docs/outputs.tf index b087e01..47820bc 100644 --- a/tofu/modules/docs/outputs.tf +++ b/tofu/modules/docs/outputs.tf @@ -1,3 +1,4 @@ output "endpoint_url" { - value = aws_route53_record.endpoint.fqdn + description = "The URL of the documentation endpoint." + value = aws_route53_record.endpoint["A"].fqdn } diff --git a/tofu/modules/docs/templates/bucket-policy.yaml.tftpl b/tofu/modules/docs/templates/bucket-policy.yaml.tftpl index bc0c56e..b65a0ba 100644 --- a/tofu/modules/docs/templates/bucket-policy.yaml.tftpl +++ b/tofu/modules/docs/templates/bucket-policy.yaml.tftpl @@ -22,3 +22,14 @@ Statement: Condition: StringEquals: aws:SourceVpce: "${vpc_endpoint_id}" +- Sid: Allow CloudFront to serve content + Effect: Allow + Principal: + Service: cloudfront.amazonaws.com + Action: + - s3:GetObject + Resource: + - "${bucket_arn}/*" + Condition: + ArnLike: + aws:SourceArn: "${cloudfront_distribution_arn}" diff --git a/tofu/modules/docs/templates/key-policy.yaml.tftpl b/tofu/modules/docs/templates/key-policy.yaml.tftpl index 5dd94fe..f82be7e 100644 --- a/tofu/modules/docs/templates/key-policy.yaml.tftpl +++ b/tofu/modules/docs/templates/key-policy.yaml.tftpl @@ -23,3 +23,15 @@ Statement: kms:EncryptionContext:aws:s3:arn: - arn:${partition}:s3:::${bucket_name} - arn:${partition}:s3:::${bucket_name}/* +- Sid: Allow CloudFront to decrypt bucket objects + Effect: Allow + Principal: + Service: cloudfront.amazonaws.com + Action: + - kms:Decrypt + - kms:Encrypt + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + aws:SourceArn: "${cloudfront_distribution_arn}" From 3d0a61509017db92e95a9d902292807d1b77d003 Mon Sep 17 00:00:00 2001 From: James Armes Date: Wed, 2 Jul 2025 14:17:14 -0400 Subject: [PATCH 7/7] fix: Removed unused variables. --- tofu/config/development/docs/main.tf | 5 ----- tofu/modules/docs/data.tf | 6 ------ tofu/modules/docs/endpoint.tf | 2 ++ tofu/modules/docs/local.tf | 1 - tofu/modules/docs/variables.tf | 5 ----- 5 files changed, 2 insertions(+), 17 deletions(-) diff --git a/tofu/config/development/docs/main.tf b/tofu/config/development/docs/main.tf index a75c4e8..a177345 100644 --- a/tofu/config/development/docs/main.tf +++ b/tofu/config/development/docs/main.tf @@ -20,9 +20,4 @@ module "docs" { # TODO: Use data resources to look this up. logging_bucket = "shared-services-development-logs" vpc_id = "vpc-024d66fcc4f521d0a" - public_subnets = [ - "subnet-04a634af483d94adb", - "subnet-0ff03c3903c4c785c", - "subnet-037226b6f514e6dd1" - ] } diff --git a/tofu/modules/docs/data.tf b/tofu/modules/docs/data.tf index f1c3027..c1b9199 100644 --- a/tofu/modules/docs/data.tf +++ b/tofu/modules/docs/data.tf @@ -13,12 +13,6 @@ data "aws_vpc_endpoint" "s3" { service_name = "com.amazonaws.${data.aws_region.current.name}.s3" } -data "aws_network_interface" "s3" { - for_each = toset(data.aws_vpc_endpoint.s3.network_interface_ids) - - id = each.value -} - data "aws_cloudfront_cache_policy" "endpoint" { name = "Managed-CachingOptimized" } diff --git a/tofu/modules/docs/endpoint.tf b/tofu/modules/docs/endpoint.tf index ac8e138..9baf2c6 100644 --- a/tofu/modules/docs/endpoint.tf +++ b/tofu/modules/docs/endpoint.tf @@ -20,6 +20,8 @@ resource "aws_cloudfront_function" "endpoint_rewrite" { } # TODO: Request OIDC authentication for the CloudFront distribution. +# TODO: Use a WAF? +#trivy:ignore:AVD-AWS-0011 resource "aws_cloudfront_distribution" "endpoint" { enabled = true comment = "Serve static documentation from S3." diff --git a/tofu/modules/docs/local.tf b/tofu/modules/docs/local.tf index 76c560c..ca1b1f7 100644 --- a/tofu/modules/docs/local.tf +++ b/tofu/modules/docs/local.tf @@ -1,7 +1,6 @@ locals { fqdn = "${var.subdomain}.${var.domain}" prefix = "cfa-documentation-${var.environment}" - s3_ips = [for ni in data.aws_network_interface.s3 : ni.private_ip] tags_base = { application = local.prefix program = "engineering" diff --git a/tofu/modules/docs/variables.tf b/tofu/modules/docs/variables.tf index c6c15d4..8b9749a 100644 --- a/tofu/modules/docs/variables.tf +++ b/tofu/modules/docs/variables.tf @@ -26,11 +26,6 @@ variable "logging_bucket" { type = string } -variable "public_subnets" { - description = "List of public subnets for the application." - type = list(string) -} - variable "subdomain" { description = "The subdomain for the documentation hosting." type = string