Skip to content

Commit 2f9329e

Browse files
authored
[Issue #1547] Deploy analytics ECS service (#1574)
## Summary Fixes #1547 ### Time to review: __5 mins__ ### Overview We need to deploy the analytics service to ECS. The problem is, is that the analytics service is entirely task-based. It is not a web server. Which means, it is not truly a great fit for being deployed by our current `infra/modules/service` module. Specifically, the task service doesn't need a load balancer or any of its associated networking components. This created a decision for me, with 3 options: 1. Make the analytics service deploy itself with a useless load balancer. This would waste money, and would significantly confuse our security scans. 2. Add conditionals into the existing `infra/modules/service` module to only conditionally deploy the load balancer. This would require doing a lot of `terraform state mv` migration since terraform resources with `count` based conditionals have slightly different terraform state paths. 3. Create a new module without the load balancer. This creates a large git diff to review. Ultimately I chose **option 3**, so that is what you see here. A new terraform module that is simply the same ECS service module we already had, but without a load balancer. ## My asks of reviewers 1. Do you think that option 3 was a reasonable choice here? 2. I named the module `infra/task-service`, can you think of a better name for it? ## Changes proposed This PR is, once again, 95% copy paste. - `infra/analytics/service` changes were copy pasted from `infra/api/service` - `infra/modules/task-service` changes were copy pasted from `infra/modules/service` ## Testing Deployed service: <img width="1290" alt="image" src="https://github.com/HHS/simpler-grants-gov/assets/5768468/a4f85ece-0128-4096-b593-4c92df5acf3d"> Deployed task definition: <img width="1297" alt="image" src="https://github.com/HHS/simpler-grants-gov/assets/5768468/0e670642-7377-42ef-a326-102385aad1fc">
1 parent 306210f commit 2f9329e

File tree

17 files changed

+794
-0
lines changed

17 files changed

+794
-0
lines changed

infra/analytics/app-config/env-config/outputs.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ output "service_config" {
2323
secrets = toset(local.secrets)
2424
}
2525
}
26+
27+
output "domain" {
28+
value = var.domain
29+
}

infra/analytics/app-config/env-config/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ variable "has_database" {
1717
default = true
1818
}
1919

20+
variable "domain" {
21+
type = string
22+
description = "DNS domain of the website managed by HHS"
23+
default = null
24+
}
25+
2026
variable "database_instance_count" {
2127
description = "Number of database instances. Should be 2+ for production environments."
2228
type = number

infra/analytics/service/.terraform.lock.hcl

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
bucket = "simpler-grants-gov-315341936575-us-east-1-tf"
2+
key = "infra/analytics/service/dev.tfstate"
3+
dynamodb_table = "simpler-grants-gov-315341936575-us-east-1-tf-state-locks"
4+
region = "us-east-1"

infra/analytics/service/image_tag.tf

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Make the "image_tag" variable optional so that "terraform plan"
2+
# and "terraform apply" work without any required variables.
3+
#
4+
# This works as follows:
5+
6+
# 1. Accept an optional variable during a terraform plan/apply. (see "image_tag" variable in variables.tf)
7+
8+
# 2. Read the output used from the last terraform state using "terraform_remote_state".
9+
# Get the backend config by parsing the backend config file
10+
locals {
11+
backend_config_file_path = "${path.module}/${var.environment_name}.s3.tfbackend"
12+
backend_config_file = file("${path.module}/${var.environment_name}.s3.tfbackend")
13+
14+
# Use regex to parse backend config file to get a map of variables to their
15+
# defined values since there is no built-in terraform function that does that
16+
#
17+
# The backend config file consists of lines that look like
18+
# <variable_name> = "<variable_value"
19+
# so our regex is (\w+)\s+= "(.+)"
20+
# Note that backslashes in the regex need to be escaped in Terraform
21+
# so they will appear as \\ instead of \
22+
# (see https://developer.hashicorp.com/terraform/language/functions/regex)
23+
backend_config_regex = "(\\w+)\\s+= \"(.+)\""
24+
backend_config = { for match in regexall(local.backend_config_regex, local.backend_config_file) : match[0] => match[1] }
25+
tfstate_bucket = local.backend_config["bucket"]
26+
tfstate_key = local.backend_config["key"]
27+
}
28+
data "terraform_remote_state" "current_image_tag" {
29+
# Don't do a lookup if image_tag is provided explicitly.
30+
# This saves some time and also allows us to do a first deploy,
31+
# where the tfstate file does not yet exist.
32+
count = var.image_tag == null ? 1 : 0
33+
backend = "s3"
34+
35+
config = {
36+
bucket = local.tfstate_bucket
37+
key = local.tfstate_key
38+
region = local.service_config.region
39+
}
40+
41+
defaults = {
42+
image_tag = null
43+
}
44+
}
45+
46+
# 3. Prefer the given variable if provided, otherwise default to the value from last time.
47+
locals {
48+
image_tag = (var.image_tag == null
49+
? data.terraform_remote_state.current_image_tag[0].outputs.image_tag
50+
: var.image_tag)
51+
}
52+
53+
# 4. Store the final value used as a terraform output for next time.
54+
output "image_tag" {
55+
value = local.image_tag
56+
}

infra/analytics/service/main.tf

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc
2+
data "aws_vpc" "network" {
3+
filter {
4+
name = "tag:Name"
5+
values = [module.project_config.network_configs[var.environment_name].vpc_name]
6+
}
7+
}
8+
9+
# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet
10+
data "aws_subnets" "private" {
11+
filter {
12+
name = "vpc-id"
13+
values = [data.aws_vpc.network.id]
14+
}
15+
filter {
16+
name = "tag:subnet_type"
17+
values = ["private"]
18+
}
19+
}
20+
21+
# docs: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet
22+
data "aws_subnets" "public" {
23+
filter {
24+
name = "vpc-id"
25+
values = [data.aws_vpc.network.id]
26+
}
27+
filter {
28+
name = "tag:subnet_type"
29+
values = ["public"]
30+
}
31+
}
32+
33+
locals {
34+
# The prefix key/value pair is used for Terraform Workspaces, which is useful for projects with multiple infrastructure developers.
35+
# By default, Terraform creates a workspace named “default.” If a non-default workspace is not created this prefix will equal “default”,
36+
# if you choose not to use workspaces set this value to "dev"
37+
prefix = terraform.workspace == "default" ? "" : "${terraform.workspace}-"
38+
39+
# Add environment specific tags
40+
tags = merge(module.project_config.default_tags, {
41+
environment = var.environment_name
42+
description = "Application resources created in ${var.environment_name} environment"
43+
})
44+
45+
service_name = "${local.prefix}${module.app_config.app_name}-${var.environment_name}"
46+
47+
is_temporary = startswith(terraform.workspace, "t-")
48+
49+
environment_config = module.app_config.environment_configs[var.environment_name]
50+
service_config = local.environment_config.service_config
51+
database_config = local.environment_config.database_config
52+
domain = local.environment_config.domain
53+
}
54+
55+
terraform {
56+
required_version = ">= 1.2.0, < 2.0.0"
57+
58+
required_providers {
59+
aws = {
60+
source = "hashicorp/aws"
61+
version = "~> 5.34.0"
62+
}
63+
}
64+
65+
backend "s3" {
66+
encrypt = "true"
67+
}
68+
}
69+
70+
provider "aws" {
71+
region = local.service_config.region
72+
default_tags {
73+
tags = local.tags
74+
}
75+
}
76+
77+
module "project_config" {
78+
source = "../../project-config"
79+
}
80+
81+
module "app_config" {
82+
source = "../app-config"
83+
}
84+
85+
data "aws_rds_cluster" "db_cluster" {
86+
count = 1
87+
cluster_identifier = local.database_config.cluster_name
88+
}
89+
90+
data "aws_acm_certificate" "cert" {
91+
count = local.domain != null ? 1 : 0
92+
domain = local.domain
93+
}
94+
95+
data "aws_iam_policy" "app_db_access_policy" {
96+
count = 1
97+
name = local.database_config.app_access_policy_name
98+
}
99+
100+
data "aws_iam_policy" "migrator_db_access_policy" {
101+
count = 1
102+
name = local.database_config.migrator_access_policy_name
103+
}
104+
105+
module "service" {
106+
source = "../../modules/task-service"
107+
service_name = local.service_name
108+
is_temporary = false
109+
image_repository_name = module.app_config.image_repository_name
110+
image_tag = local.image_tag
111+
vpc_id = data.aws_vpc.network.id
112+
public_subnet_ids = data.aws_subnets.public.ids
113+
private_subnet_ids = data.aws_subnets.private.ids
114+
cpu = 1024
115+
memory = 2048
116+
117+
# This is a task based service, not a web server, so we don't need to run any instances of the service at rest.
118+
desired_instance_count = 0
119+
120+
cert_arn = local.domain != null ? data.aws_acm_certificate.cert[0].arn : null
121+
122+
db_vars = {
123+
security_group_ids = data.aws_rds_cluster.db_cluster[0].vpc_security_group_ids
124+
app_access_policy_arn = data.aws_iam_policy.app_db_access_policy[0].arn
125+
migrator_access_policy_arn = data.aws_iam_policy.migrator_db_access_policy[0].arn
126+
connection_info = {
127+
host = data.aws_rds_cluster.db_cluster[0].endpoint
128+
port = data.aws_rds_cluster.db_cluster[0].port
129+
user = local.database_config.app_username
130+
db_name = data.aws_rds_cluster.db_cluster[0].database_name
131+
schema_name = local.database_config.schema_name
132+
}
133+
}
134+
135+
extra_environment_variables = local.service_config.extra_environment_variables
136+
secrets = local.service_config.secrets
137+
}

infra/analytics/service/outputs.tf

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
output "service_cluster_name" {
2+
value = module.service.cluster_name
3+
}
4+
5+
output "service_name" {
6+
value = local.service_name
7+
}
8+
9+
output "application_log_group" {
10+
value = module.service.application_log_group
11+
}
12+
13+
output "application_log_stream_prefix" {
14+
value = module.service.application_log_stream_prefix
15+
}
16+
17+
output "migrator_role_arn" {
18+
value = module.service.migrator_role_arn
19+
}

infra/analytics/service/variables.tf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
variable "environment_name" {
2+
type = string
3+
description = "name of the application environment"
4+
}
5+
6+
variable "image_tag" {
7+
type = string
8+
description = "image tag to deploy to the environment"
9+
default = null
10+
}

infra/modules/task-service/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# modules/task-service
2+
3+
This module is functionally the same module as `modules/service`, but with the load balancer and associated networking components removed.
4+
5+
This module (eg. `modules/task-service`) is meant for use with services that composed of individually run tasks. The modules it was based off of
6+
(eg. `modules/service`) is meant for use with web servers.

0 commit comments

Comments
 (0)