diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index d0efd88..9d9e399 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. @@ -16,8 +22,8 @@ permissions: jobs: deploy: - name: Deploy Documentation - environment: 'docs-dev' + name: Deploy Documentation to ${{ inputs.environment }} + environment: ${{ inputs.environment }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -26,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: us-east-1 + aws-region: ${{ env.AWS_REGION || 'us-east-1' }} - uses: actions/setup-python@v5 with: python-version: 3.x @@ -39,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://${{ env.BUCKET_NAME || 'dev.docs.cfa.codes' }}/${{ env.PREFIX || 'shared-services' }}" + - run: aws s3 sync ./site "s3://${{ env.DOCS_BUCKET || 'docs.dev.services.cfa.codes' }}/${{ env.PREFIX || 'shared-services' }}" 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..a177345 --- /dev/null +++ b/tofu/config/development/docs/main.tf @@ -0,0 +1,23 @@ +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" +} diff --git a/tofu/config/development/docs/outputs.tf b/tofu/config/development/docs/outputs.tf new file mode 100644 index 0000000..83f086e --- /dev/null +++ b/tofu/config/development/docs/outputs.tf @@ -0,0 +1,4 @@ +output "endpoint_url" { + description = "The URL of the documentation endpoint." + 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/data.tf b/tofu/modules/docs/data.tf new file mode 100644 index 0000000..c1b9199 --- /dev/null +++ b/tofu/modules/docs/data.tf @@ -0,0 +1,18 @@ +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_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..9baf2c6 --- /dev/null +++ b/tofu/modules/docs/endpoint.tf @@ -0,0 +1,127 @@ +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. +# TODO: Use a WAF? +#trivy:ignore:AVD-AWS-0011 +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/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..ca1b1f7 --- /dev/null +++ b/tofu/modules/docs/local.tf @@ -0,0 +1,11 @@ +locals { + fqdn = "${var.subdomain}.${var.domain}" + prefix = "cfa-documentation-${var.environment}" + 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..0ec3333 --- /dev/null +++ b/tofu/modules/docs/main.tf @@ -0,0 +1,59 @@ +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, + cloudfront_distribution_arn : aws_cloudfront_distribution.endpoint.arn, + }))) + + 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 + 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" + 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 new file mode 100644 index 0000000..47820bc --- /dev/null +++ b/tofu/modules/docs/outputs.tf @@ -0,0 +1,4 @@ +output "endpoint_url" { + 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 new file mode 100644 index 0000000..b65a0ba --- /dev/null +++ b/tofu/modules/docs/templates/bucket-policy.yaml.tftpl @@ -0,0 +1,35 @@ +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}" +- 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 new file mode 100644 index 0000000..f82be7e --- /dev/null +++ b/tofu/modules/docs/templates/key-policy.yaml.tftpl @@ -0,0 +1,37 @@ +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}/* +- 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}" diff --git a/tofu/modules/docs/variables.tf b/tofu/modules/docs/variables.tf new file mode 100644 index 0000000..8b9749a --- /dev/null +++ b/tofu/modules/docs/variables.tf @@ -0,0 +1,38 @@ +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 "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" + } + } +}