shoc-backend/terraform/live/modules/environment-owned/main.tf
Adam Moussa 8f4fa36647
refactor(cd): ship Elastic Beanstalk versions from GitHub on main
Keep application and Terraform changes in separate PRs so a merge cannot race an HCP apply against an app deploy.
2026-09-17 15:54:31 -04:00

561 lines
16 KiB
HCL

data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
data "aws_elastic_beanstalk_hosted_zone" "current" {}
locals {
application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:application/${var.eb_application_name}"
environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}"
environment_stack_name = "awseb-${var.eb_environment_id}-stack"
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
use_legacy_s3_policy = var.legacy_dev_s3_policy
app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*"
deploy_ssm_prefix = "/shoc-backend/${var.environment}/deploy"
}
data "aws_iam_policy_document" "runtime_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "runtime" {
name = var.runtime_role_name
path = "/"
description = var.metadata_before_adoption.runtime_role_description
assume_role_policy = data.aws_iam_policy_document.runtime_assume.json
max_session_duration = 3600
permissions_boundary = var.permissions_boundary_arn
tags = var.adoption_complete ? merge(var.metadata_before_adoption.runtime_role_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.runtime_role_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy_attachment" "web_tier" {
role = aws_iam_role.runtime.name
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "runtime_app_config" {
statement {
sid = var.app_config_policy_sid
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [local.app_config_secret_pattern]
}
}
resource "aws_iam_role_policy" "runtime_app_config" {
name = var.runtime_app_config_policy_name
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_app_config.json
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "runtime_webhook" {
count = var.work_order_webhook_enabled ? 1 : 0
statement {
sid = var.webhook_read_policy_sid
effect = "Allow"
actions = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
]
resources = [var.webhook_secret_arn]
}
statement {
sid = var.webhook_decrypt_policy_sid
effect = "Allow"
actions = ["kms:Decrypt"]
resources = ["arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18"]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["secretsmanager.us-east-1.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy" "runtime_webhook" {
count = var.work_order_webhook_enabled ? 1 : 0
name = var.runtime_webhook_policy_name
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_webhook[0].json
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "runtime_dynamo" {
count = var.dynamo_reader_role_arn == null ? 0 : 1
statement {
sid = var.dynamo_policy_sid
effect = "Allow"
actions = ["sts:AssumeRole"]
resources = [var.dynamo_reader_role_arn]
}
}
resource "aws_iam_role_policy" "runtime_dynamo" {
count = var.dynamo_reader_role_arn == null ? 0 : 1
name = var.runtime_dynamo_policy_name
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_dynamo[0].json
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_instance_profile" "runtime" {
name = var.runtime_role_name
path = "/"
role = aws_iam_role.runtime.name
tags = var.adoption_complete ? merge(var.metadata_before_adoption.instance_profile_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.instance_profile_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_secretsmanager_secret" "app_config" {
# Terraform owns the secret shell and metadata only. Values remain out of
# band and must never be declared in this resource or its callers.
name = var.app_config_secret_name
description = var.metadata_before_adoption.app_config_description
tags = var.adoption_complete ? merge(var.metadata_before_adoption.app_config_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.app_config_tags
lifecycle {
prevent_destroy = true
ignore_changes = [
force_overwrite_replica_secret,
recovery_window_in_days,
]
}
}
data "aws_iam_policy_document" "deploy_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:environment:${var.github_environment}"]
}
# StringLike: a tag-loaded reusable workflow uses @refs/tags/v*, while
# push and workflow_dispatch use @refs/heads/main. Adding a deploy
# workflow means adding its ref here (cross-family IAM).
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/main",
"${var.github_repo}/.github/workflows/deploy.yaml@refs/tags/v*",
]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = var.github_deploy_role_name
path = "/"
description = var.metadata_before_adoption.deploy_role_description
assume_role_policy = data.aws_iam_policy_document.deploy_assume.json
max_session_duration = 3600
permissions_boundary = var.github_deploy_permissions_boundary_arn
tags = var.adoption_complete ? merge(var.metadata_before_adoption.deploy_role_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.deploy_role_tags
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "deploy" {
statement {
effect = "Allow"
actions = [
"autoscaling:Describe*",
"ec2:Describe*",
"elasticbeanstalk:DescribeApplicationVersions",
"elasticbeanstalk:DescribeEnvironments",
"elasticbeanstalk:DescribeEvents",
"elasticloadbalancing:Describe*",
]
resources = ["*"]
}
statement {
effect = "Allow"
actions = ["elasticbeanstalk:CreateApplicationVersion"]
resources = [
local.application_arn,
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:applicationversion/${var.eb_application_name}/*",
]
}
statement {
effect = "Allow"
actions = ["elasticbeanstalk:UpdateEnvironment"]
resources = [local.environment_arn]
}
statement {
effect = "Allow"
actions = [
"cloudformation:CancelUpdateStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:UpdateStack",
]
resources = [
"arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*",
]
}
statement {
effect = "Allow"
actions = [
"autoscaling:PutNotificationConfiguration",
"autoscaling:ResumeProcesses",
"autoscaling:SuspendProcesses",
]
resources = [
"arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*",
]
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [1] : []
content {
effect = "Allow"
actions = ["s3:Delete*", "s3:Get*", "s3:Put*"]
resources = ["arn:aws:s3:::elasticbeanstalk-*/*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [1] : []
content {
effect = "Allow"
actions = [
"s3:GetBucket*",
"s3:ListBucket",
"s3:PutBucketOwnershipControls",
"s3:PutBucketPolicy",
"s3:PutBucketPublicAccessBlock",
]
resources = ["arn:aws:s3:::elasticbeanstalk-*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
}
}
statement {
sid = "ReadDeployParameters"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/shoc-backend/${var.environment}/deploy/*",
]
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = var.github_deploy_policy_name
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.deploy.json
lifecycle {
prevent_destroy = true
}
}
resource "aws_ssm_parameter" "deploy_application_name" {
name = "${local.deploy_ssm_prefix}/application-name"
type = "String"
value = var.eb_application_name
description = "Elastic Beanstalk application name; GitHub CD creates application versions here"
}
resource "aws_ssm_parameter" "deploy_environment_name" {
name = "${local.deploy_ssm_prefix}/environment-name"
type = "String"
value = var.eb_environment_name
description = "Elastic Beanstalk environment name; GitHub CD calls UpdateEnvironment"
}
resource "aws_ssm_parameter" "deploy_artifacts_bucket" {
name = "${local.deploy_ssm_prefix}/artifacts-bucket"
type = "String"
value = local.eb_bucket_name
description = "Bucket for release zips; GitHub CD uploads site.zip here"
}
resource "aws_ssm_parameter" "deploy_smoke_url" {
name = "${local.deploy_ssm_prefix}/smoke-url"
type = "String"
value = var.smoke_url
description = "HTTPS origin for post-deploy smoke checks"
}
locals {
managed_eb_settings = concat(
[
{
namespace = "aws:elasticbeanstalk:environment"
name = "EnvironmentType"
value = "LoadBalanced"
},
{
namespace = "aws:elasticbeanstalk:environment"
name = "LoadBalancerType"
value = "application"
},
{
namespace = "aws:elasticbeanstalk:environment"
name = "ServiceRole"
value = var.eb_service_role_name
},
{
namespace = "aws:ec2:vpc"
name = "VPCId"
value = var.vpc_id
},
{
namespace = "aws:ec2:vpc"
name = "Subnets"
value = join(",", sort(var.instance_subnet_ids))
},
{
namespace = "aws:ec2:vpc"
name = "ELBSubnets"
value = join(",", sort(var.load_balancer_subnet_ids))
},
{
namespace = "aws:ec2:vpc"
name = "ELBScheme"
value = "public"
},
{
namespace = "aws:ec2:vpc"
name = "AssociatePublicIpAddress"
value = "true"
},
{
namespace = "aws:autoscaling:launchconfiguration"
name = "IamInstanceProfile"
value = aws_iam_instance_profile.runtime.name
},
{
namespace = "aws:autoscaling:launchconfiguration"
name = "InstanceType"
value = "t3.small"
},
{
namespace = "aws:autoscaling:asg"
name = "MinSize"
value = "1"
},
{
namespace = "aws:autoscaling:asg"
name = "MaxSize"
value = "1"
},
{
namespace = "aws:elbv2:listener:443"
name = "Protocol"
value = "HTTPS"
},
{
namespace = "aws:elbv2:listener:443"
name = "SSLCertificateArns"
value = var.shared_certificate_arn
},
{
namespace = "aws:elasticbeanstalk:environment:process:default"
name = "HealthCheckPath"
value = "/"
},
{
namespace = "aws:elasticbeanstalk:environment:process:default"
name = "MatcherHTTPCode"
value = "200-499"
},
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "ASPNETCORE_ENVIRONMENT"
value = "Production"
},
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "ASPNETCORE_URLS"
value = "http://0.0.0.0:5000"
},
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__Enabled"
value = var.work_order_webhook_enabled ? "true" : "false"
},
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__Region"
value = var.aws_region
},
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "SENTRY_DSN"
value = var.sentry_dsn
},
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "SENTRY_ENVIRONMENT"
value = var.environment == "dev" ? "development" : var.environment
},
],
var.instance_security_group_id == null ? [] : [
{
namespace = "aws:autoscaling:launchconfiguration"
name = "SecurityGroups"
value = var.instance_security_group_id
},
],
[
for key in sort(tolist(var.app_config_json_keys)) : {
namespace = "aws:elasticbeanstalk:application:environmentsecrets"
name = key
value = "${aws_secretsmanager_secret.app_config.arn}:${key}"
}
],
var.webhook_secret_arn == null ? [] : [
{
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__SecretId"
value = var.webhook_secret_arn
},
],
)
}
resource "aws_elastic_beanstalk_environment" "this" {
# GitHub Actions owns application versions via UpdateEnvironment.
# version_label is ignored so app deploys are not Terraform drift.
name = var.eb_environment_name
application = var.eb_application_name
platform_arn = var.platform_arn
tier = "WebServer"
cname_prefix = var.eb_environment_name
dynamic "setting" {
for_each = var.manage_eb_settings ? local.managed_eb_settings : []
content {
namespace = setting.value.namespace
name = setting.value.name
value = setting.value.value
}
}
tags = var.metadata_before_adoption.environment_tags
lifecycle {
prevent_destroy = true
ignore_changes = [
wait_for_ready_timeout,
version_label,
]
}
}
resource "aws_route53_record" "api_alias" {
count = var.api_record_type == "A" ? 1 : 0
zone_id = var.hosted_zone_id
name = var.api_domain
type = "A"
alias {
name = var.api_alias_target == null ? aws_elastic_beanstalk_environment.this.cname : var.api_alias_target.name
zone_id = var.api_alias_target == null ? data.aws_elastic_beanstalk_hosted_zone.current.id : var.api_alias_target.zone_id
evaluate_target_health = true
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_route53_record" "api_cname" {
count = var.api_record_type == "CNAME" ? 1 : 0
zone_id = var.hosted_zone_id
name = var.api_domain
type = "CNAME"
ttl = 60
records = [
var.api_cname_target == null ? aws_elastic_beanstalk_environment.this.endpoint_url : var.api_cname_target,
]
lifecycle {
prevent_destroy = true
}
}