diff --git a/database-monitoring/terraform/.module.toml b/database-monitoring/terraform/.module.toml index beaa8cf..3c41b87 100644 --- a/database-monitoring/terraform/.module.toml +++ b/database-monitoring/terraform/.module.toml @@ -1,6 +1,6 @@ [module] name = "aws-elastio-database-monitoring-agent" -version = "0.2.1" +version = "0.3.0" description = "Terraform module that runs the Elastio database monitoring agent on AWS Fargate" type = "terraform" diff --git a/database-monitoring/terraform/.terraform.lock.hcl b/database-monitoring/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..a58e9f6 --- /dev/null +++ b/database-monitoring/terraform/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.67.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=", + "zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0", + "zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95", + "zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f", + "zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67", + "zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3", + "zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2", + "zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8", + "zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63", + "zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263", + "zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6", + "zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994", + "zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1", + "zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546", + "zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = ">= 3.0.0" + hashes = [ + "h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} diff --git a/database-monitoring/terraform/README.md b/database-monitoring/terraform/README.md index c99c02c..16a3c2e 100644 --- a/database-monitoring/terraform/README.md +++ b/database-monitoring/terraform/README.md @@ -65,6 +65,7 @@ A security group with the usual allow-all egress already allows all three. The d - A Fargate task definition of `task_cpu` and `task_memory` (by default 0.25 vCPU and 0.5 GB), ARM64, with a read-only root filesystem. The task runs as the image's non-root user, with `GOMEMLIMIT` set to 80% of the agent's memory: `task_memory`, less 384 MiB with `content_analysis = true`. - With `content_analysis = true`, a second container in the same task, running Elastio's encryption detector (`ed_image`) as the agent's non-root user. Its gRPC and metrics ports listen on loopback only. The agent needs no inbound rules at all. It reads a task-local scratch volume at `/var/lib/elastio-dbmon-ed`, read-only; see **Content analysis** below. - A service with `desired_count = 1`. Its deployment policy (minimum healthy 0%, maximum 100%) stops the old task before it starts the new one. A replication slot allows only one consumer at a time, and the agent's state allows only one writer. Don't raise the maximum. +- With `updater = true`, a second service in the same cluster, `-updater`, running Elastio's deployment updater; see **Runtime image upgrades** below. ## Content analysis @@ -113,6 +114,7 @@ The other sizes are in the Sizing table above. On top of the task: - **Secrets Manager:** three secrets at $0.40 each, so $1.20 a month. - **EFS:** the agent's state stays at tens of MB, which is cents a month. Mount targets and access points are free. +- **Updater:** with `updater = true`, a second task of the smallest size, so another $7.21 a month. - **Ephemeral storage and CloudWatch:** the extra GB of ephemeral storage above the free 20 GB, and CloudWatch log ingestion, each cost a few cents. If the subnets use a NAT gateway, its hourly charge will be your largest cost by far. You pay that charge whether or not you deploy this module. @@ -153,8 +155,8 @@ terraform init -backend=false && terraform test | Name | Version | | --------------------------------------------------------- | ------- | -| [aws](#provider_aws) | >= 5.0 | -| [random](#provider_random) | >= 3.0 | +| [aws](#provider_aws) | 6.67.0 | +| [random](#provider_random) | 3.9.1 | ## Modules @@ -167,16 +169,23 @@ No modules. | [aws_cloudwatch_log_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_ecs_cluster.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource | | [aws_ecs_service.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_service.updater](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | | [aws_ecs_task_definition.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_ecs_task_definition.updater](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | | [aws_efs_access_point.ledger](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource | | [aws_efs_file_system.ledger](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_file_system) | resource | | [aws_efs_file_system_policy.ledger](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_file_system_policy) | resource | | [aws_efs_mount_target.ledger](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_mount_target) | resource | | [aws_iam_role.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.updater_execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.updater_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.mount_ledger](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.read_secrets](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.updater_deploy_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.updater_read_api_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.updater_execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_secretsmanager_secret.api_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | | [aws_secretsmanager_secret.database_url](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | | [aws_secretsmanager_secret.hash_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | @@ -194,6 +203,7 @@ No modules. | Name | Description | Type | Default | Required | | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------ | :------: | +| [agent_id](#input_agent_id) | The agent's ID in Elastio, a UUID: the agent logs it on every start as "registered as " in this module's log group. Required when updater = true: the updater cannot discover it, and without it it runs but never updates the agent. | `string` | `""` | no | | [api_key](#input_api_key) | Bearer token the agent presents to the server. Stored in Secrets Manager, never in the task definition. | `string` | n/a | yes | | [assign_public_ip](#input_assign_public_ip) | Give the task a public IP. Needed only when the subnets have no NAT gateway and the server URL is on the internet. | `bool` | `false` | no | | [content_analysis](#input_content_analysis) | Run Elastio's encryption detector beside the agent, so a table whose rows are overwritten with ciphertext is reported as a high-severity finding even where no rewrite limit is declared. The agent samples the rewritten values in memory and on task-local storage only, for the detector to read; only the verdict leaves the task. Needs agent 0.1.7 or later and task_memory of at least 1024. | `bool` | `false` | no | @@ -212,33 +222,63 @@ No modules. | [tags](#input_tags) | Tags applied to every resource the module creates. | `map(string)` | `{}` | no | | [task_cpu](#input_task_cpu) | CPU units for the Fargate task: 256 (0.25 vCPU), 512, 1024 or 2048. Together with task_memory it must be a size Fargate runs on ARM64; see Sizing in the README. | `number` | `256` | no | | [task_memory](#input_task_memory) | Memory for the Fargate task, in MiB. With task_cpu 256: 512, 1024 or 2048. With 512: 1024 to 4096. With 1024: 2048 to 8192. With 2048: 4096 to 16384. Above 512, in steps of 1024. The agent's Go memory limit follows it. Raise it when the Elastio UI recommends a larger size. | `number` | `512` | no | +| [update_channel](#input_update_channel) | Release channel the updater installs agents from: production or development. | `string` | `"production"` | no | +| [updater](#input_updater) | Run Elastio's deployment updater as a second ECS service in the agent's cluster, so an agent update started from Elastio is applied to this service. Requires runtime_updates = true, so a later apply keeps the image the updater deployed. Enable both after the first apply: runtime_updates reads the service, which must exist. | `bool` | `false` | no | +| [updater_image](#input_updater_image) | Deployment updater image, used when updater is true. The default follows update_channel: the updater released with this module version for production, the latest development build for development. | `string` | `null` | no | ## Outputs -| Name | Description | -| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| [cluster_arn](#output_cluster_arn) | ARN of the ECS cluster the agent runs in. | -| [execution_role_arn](#output_execution_role_arn) | IAM role ECS uses to pull the image, read the three secrets and write logs. | -| [hash_secret_arn](#output_hash_secret_arn) | ARN of the Secrets Manager secret holding the agent's key. It must stay stable for the life of the agent's state. | -| [ledger_file_system_id](#output_ledger_file_system_id) | ID of the EFS file system holding the agent's state, or null when persistent_ledger is false. | -| [log_group_name](#output_log_group_name) | CloudWatch log group the agent writes to. | -| [service_name](#output_service_name) | Name of the ECS service. | -| [task_role_arn](#output_task_role_arn) | IAM role the running container assumes. The agent has no AWS code; with persistent state the role may mount the state file system, and nothing else. | +| Name | Description | +| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [cluster_arn](#output_cluster_arn) | ARN of the ECS cluster the agent runs in. | +| [execution_role_arn](#output_execution_role_arn) | IAM role ECS uses to pull the image, read the three secrets and write logs. | +| [hash_secret_arn](#output_hash_secret_arn) | ARN of the Secrets Manager secret holding the agent's key. It must stay stable for the life of the agent's state. | +| [ledger_file_system_id](#output_ledger_file_system_id) | ID of the EFS file system holding the agent's state, or null when persistent_ledger is false. | +| [log_group_name](#output_log_group_name) | CloudWatch log group the agent writes to. | +| [service_name](#output_service_name) | Name of the ECS service. | +| [task_role_arn](#output_task_role_arn) | IAM role the running container assumes. The agent has no AWS code; with persistent state the role may mount the state file system, and nothing else. | +| [updater_service_name](#output_updater_service_name) | Name of the updater's ECS service, or null when updater is false. | +| [updater_task_role_arn](#output_updater_task_role_arn) | IAM role the updater calls AWS with, or null when updater is false. It may update only the agent's service, register only the agent's task family, and pass only the agent's two roles. | ## Runtime image upgrades -After the first deployment, set `runtime_updates = true` when installing the -Elastio deployment updater. Manual updates and optional automatic upgrades are -then controlled through Elastio. Terraform continues to own infrastructure and -CPU/memory sizing. During a size change or later apply, the module reads the -service's current task definition and preserves its agent and detector images, -including immutable digests selected by the updater. It does not reset them to -the module's older `image` defaults. Do not enable this option on a first-ever -apply: the service must exist for its deployed images to be read. - -The updater runs separately from the monitored task with permission to update -only its configured ECS service and pass the existing task/execution roles. -Installation and Docker/VM equivalents are documented in the +With `updater = true`, the module runs Elastio's deployment updater as a second +ECS service in the agent's cluster, so an agent update started from Elastio is +applied to this deployment. Terraform keeps owning infrastructure and CPU/memory +sizing; Elastio owns which agent image runs. + +```tf + runtime_updates = true + updater = true + agent_id = "7d0c2b8e-3f1a-4e57-9a7c-1b2c3d4e5f60" # "registered as " in the agent's log +``` + +Enable both **after the first apply**. `runtime_updates` reads the service's +current task definition and keeps its agent and detector images, including the +digests the updater deployed, so a size change or a later apply does not put +the module's `image` back. It needs the service to exist, so it fails on a +first-ever apply. `updater = true` without `runtime_updates = true`, or +without an `agent_id` that is a UUID, is refused at plan: the updater cannot +discover the agent's ID, and without it it would run, cost money and never +update the agent. + +The updater: + +- runs as one task of 0.25 vCPU and 512 MiB on ARM64, in `subnet_ids` with + `security_group_ids`. It needs outbound HTTPS to Elastio, to the image + registries and to the ECS API, and no inbound rules; +- authenticates to Elastio with the agent's API key, read from the agent's + secret, and acts for the agent named by `agent_id`; +- installs from `update_channel`, `production` by default. `updater_image` + defaults to the updater released for that channel; +- logs to the agent's log group, under the `updater/` stream prefix; +- has its own execution role, which reads only the API-key secret, and its own + task role, which may only describe and update the agent's service (and only + to a revision of the agent's task family), register revisions of that + family, describe task definitions, and pass the agent's task and execution + roles to ECS. + +How the updater picks, verifies and deploys an image is in the [agent updater guide](https://github.com/elastio/database-monitoring-agent/tree/master/deploy/updater). diff --git a/database-monitoring/terraform/outputs.tf b/database-monitoring/terraform/outputs.tf index b4da320..1c1fdd5 100644 --- a/database-monitoring/terraform/outputs.tf +++ b/database-monitoring/terraform/outputs.tf @@ -32,3 +32,13 @@ output "hash_secret_arn" { description = "ARN of the Secrets Manager secret holding the agent's key. It must stay stable for the life of the agent's state." value = aws_secretsmanager_secret.hash_secret.arn } + +output "updater_service_name" { + description = "Name of the updater's ECS service, or null when updater is false." + value = local.updater ? aws_ecs_service.updater[0].name : null +} + +output "updater_task_role_arn" { + description = "IAM role the updater calls AWS with, or null when updater is false. It may update only the agent's service, register only the agent's task family, and pass only the agent's two roles." + value = local.updater ? aws_iam_role.updater_task[0].arn : null +} diff --git a/database-monitoring/terraform/tests/updater.tftest.hcl b/database-monitoring/terraform/tests/updater.tftest.hcl new file mode 100644 index 0000000..aa2c1f9 --- /dev/null +++ b/database-monitoring/terraform/tests/updater.tftest.hcl @@ -0,0 +1,373 @@ +# Offline checks of the deployment updater, with mocked providers: no AWS +# account or credentials needed. Run from this directory with +# terraform init -backend=false && terraform test +# +# A file of its own so that its apply starts from empty state: a run's +# override_resource only reaches resources that run creates, and the applies +# in module.tftest.hcl have already created the agent's. + +mock_provider "aws" { + mock_data "aws_subnet" { + defaults = { availability_zone = "us-east-1a", vpc_id = "vpc-1" } + } + mock_resource "aws_cloudwatch_log_group" { + defaults = { arn = "arn:aws:logs:us-east-1:123456789012:log-group:x" } + } + mock_resource "aws_efs_file_system" { + defaults = { arn = "arn:aws:elasticfilesystem:us-east-1:123456789012:file-system/fs-1" } + } + mock_resource "aws_efs_access_point" { + defaults = { arn = "arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-1" } + } + mock_resource "aws_secretsmanager_secret" { + defaults = { arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:x" } + } + mock_resource "aws_iam_role" { + defaults = { arn = "arn:aws:iam::123456789012:role/x" } + } +} +mock_provider "random" {} + +variables { + name = "orders db" + server_url = "https://x" + api_key = "k" + database_url = "postgres://x" + subnet_ids = ["subnet-a", "subnet-b"] + security_group_ids = ["sg-1", "sg-2"] +} + +# The deployment updater. Off by default: no second service, no roles. +run "updater_is_off_by_default" { + command = plan + + assert { + condition = ( + length(aws_ecs_service.updater) == 0 && + length(aws_ecs_task_definition.updater) == 0 && + length(aws_iam_role.updater_task) == 0 && + length(aws_iam_role.updater_execution) == 0 && + length(aws_iam_role_policy.updater_deploy_agent) == 0 && + length(aws_iam_role_policy.updater_read_api_key) == 0 && + length(aws_iam_role_policy_attachment.updater_execution) == 0 + ) + error_message = "updater must be opted into" + } + assert { + condition = output.updater_service_name == null && output.updater_task_role_arn == null + error_message = "with the updater off, its outputs are null" + } +} + +# Without runtime_updates the next apply would undo what the updater deployed. +run "updater_requires_runtime_updates" { + command = plan + + variables { + updater = true + } + + expect_failures = [var.updater] +} + +# The updater cannot discover the agent's ID; without it the service would +# run, cost money and never update anything (review on #129). +run "updater_requires_agent_id" { + command = plan + + variables { + runtime_updates = true + updater = true + } + + expect_failures = [var.agent_id] +} + +run "updater_rejects_agent_id_that_is_not_a_uuid" { + command = plan + + variables { + runtime_updates = true + updater = true + agent_id = "orders-db" + } + + expect_failures = [var.agent_id] +} + +# With the updater off, agent_id stays optional. +run "agent_id_is_optional_without_the_updater" { + command = plan + + variables { + agent_id = "anything" + } +} + +run "rejects_unknown_update_channel" { + command = plan + + variables { + update_channel = "staging" + } + + expect_failures = [var.update_channel] +} + +# On: one more service, its own two roles, and a task role that can deploy +# to the agent's service and nothing else. +run "updater_deploys_only_the_agent" { + command = apply + + variables { + runtime_updates = true + updater = true + agent_id = "7d0c2b8e-3f1a-4e57-9a7c-1b2c3d4e5f60" + } + + override_data { + target = data.aws_ecs_service.runtime[0] + values = { task_definition = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db:7" } + } + override_data { + target = data.aws_ecs_task_definition.runtime[0] + values = { container_definitions = "[{\"name\":\"elastio-dbmon-agent\",\"image\":\"agent@sha256:new\"}]" } + } + override_resource { + target = aws_ecs_service.this + values = { id = "arn:aws:ecs:us-east-1:123456789012:service/orders-db/orders-db" } + } + override_resource { + target = aws_ecs_task_definition.this + values = { + arn = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db:8" + arn_without_revision = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db" + } + } + override_resource { + target = aws_iam_role.task + values = { arn = "arn:aws:iam::123456789012:role/agent-task" } + } + override_resource { + target = aws_iam_role.execution + values = { arn = "arn:aws:iam::123456789012:role/agent-exec" } + } + override_resource { + target = aws_iam_role.updater_task[0] + values = { arn = "arn:aws:iam::123456789012:role/updater-task" } + } + override_resource { + target = aws_iam_role.updater_execution[0] + values = { arn = "arn:aws:iam::123456789012:role/updater-exec" } + } + override_resource { + target = aws_secretsmanager_secret.api_key + values = { arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key" } + } + + # Exactly one more service, task definition, two roles and their policies. + assert { + condition = ( + length(aws_ecs_service.updater) == 1 && + length(aws_ecs_task_definition.updater) == 1 && + length(aws_iam_role.updater_task) == 1 && + length(aws_iam_role.updater_execution) == 1 && + length(aws_iam_role_policy.updater_deploy_agent) == 1 && + length(aws_iam_role_policy.updater_read_api_key) == 1 && + length(aws_iam_role_policy_attachment.updater_execution) == 1 + ) + error_message = "updater = true must add one service with its own task definition, two roles and their policies" + } + assert { + condition = aws_ecs_service.updater[0].name == "orders-db-updater" && aws_ecs_service.updater[0].cluster == aws_ecs_cluster.this.id && aws_ecs_service.updater[0].desired_count == 1 + error_message = "the updater must be one task in the agent's cluster" + } + assert { + condition = aws_ecs_service.updater[0].deployment_maximum_percent == 100 && aws_ecs_service.updater[0].deployment_minimum_healthy_percent == 0 + error_message = "two updaters must never run at once: they would take the same job" + } + assert { + condition = toset(one(aws_ecs_service.updater[0].network_configuration).subnets) == toset(var.subnet_ids) && toset(one(aws_ecs_service.updater[0].network_configuration).security_groups) == toset(var.security_group_ids) + error_message = "the updater must run in the agent's subnets and security groups" + } + assert { + condition = aws_ecs_task_definition.updater[0].cpu == "256" && aws_ecs_task_definition.updater[0].memory == "512" && one(aws_ecs_task_definition.updater[0].runtime_platform).cpu_architecture == "ARM64" + error_message = "the updater is the smallest Fargate task, on ARM64" + } + assert { + condition = aws_ecs_task_definition.updater[0].task_role_arn == "arn:aws:iam::123456789012:role/updater-task" && aws_ecs_task_definition.updater[0].execution_role_arn == "arn:aws:iam::123456789012:role/updater-exec" + error_message = "the updater must run with its own roles, never the agent's" + } + + # Its container: configuration from the environment, the API key from the + # agent's secret, the log in the module's log group. + assert { + condition = one(jsondecode(aws_ecs_task_definition.updater[0].container_definitions)).environment == [ + { name = "ELASTIO_DBMON_SERVER_URL", value = "https://x" }, + { name = "ELASTIO_DBMON_AGENT_ID", value = "7d0c2b8e-3f1a-4e57-9a7c-1b2c3d4e5f60" }, + { name = "ELASTIO_DBMON_DEPLOYMENT", value = "ecs" }, + { name = "ELASTIO_DBMON_UPDATE_CHANNEL", value = "production" }, + { name = "ELASTIO_DBMON_ECS_CLUSTER", value = "orders-db" }, + { name = "ELASTIO_DBMON_ECS_SERVICE", value = "orders-db" }, + { name = "ELASTIO_DBMON_ECS_CONTAINER", value = "elastio-dbmon-agent" }, + ] + error_message = "the updater's environment must name the server, agent, channel and the agent's cluster, service and container" + } + assert { + condition = one(jsondecode(aws_ecs_task_definition.updater[0].container_definitions)).secrets == [{ name = "ELASTIO_DBMON_API_KEY", valueFrom = "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key" }] + error_message = "the updater's only secret is the agent's API key" + } + assert { + condition = one(jsondecode(aws_ecs_task_definition.updater[0].container_definitions)).image == "public.ecr.aws/elastio/elastio-database-monitoring-updater:0.1.10" + error_message = "the production updater defaults to Elastio's public ECR image of this module's release" + } + assert { + condition = one(jsondecode(aws_ecs_task_definition.updater[0].container_definitions)).logConfiguration.options == { + "awslogs-group" = aws_cloudwatch_log_group.this.name + "awslogs-region" = "us-east-1" + "awslogs-stream-prefix" = "updater" + } + error_message = "the updater logs to the module's log group, under its own prefix" + } + + # The execution role reads the API key and no other secret. + assert { + condition = jsondecode(aws_iam_role_policy.updater_read_api_key[0].policy) == { + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key" + }, + ] + } + error_message = "the updater's execution role must read exactly the agent's API key" + } + assert { + condition = aws_iam_role_policy.updater_read_api_key[0].role == aws_iam_role.updater_execution[0].id && aws_iam_role_policy_attachment.updater_execution[0].role == aws_iam_role.updater_execution[0].name + error_message = "the secret and execution policies belong to the updater's execution role" + } + + # The task role, statement by statement. + assert { + condition = jsondecode(aws_iam_role_policy.updater_deploy_agent[0].policy) == { + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadAgentService" + Effect = "Allow" + Action = "ecs:DescribeServices" + Resource = "arn:aws:ecs:us-east-1:123456789012:service/orders-db/orders-db" + }, + { + Sid = "ReadTaskDefinitions" + Effect = "Allow" + Action = "ecs:DescribeTaskDefinition" + Resource = "*" + }, + { + Sid = "RegisterAgentTaskDefinition" + Effect = "Allow" + Action = "ecs:RegisterTaskDefinition" + Resource = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db:*" + }, + { + Sid = "DeployAgentService" + Effect = "Allow" + Action = "ecs:UpdateService" + Resource = "arn:aws:ecs:us-east-1:123456789012:service/orders-db/orders-db" + Condition = { ArnLike = { "ecs:task-definition" = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db:*" } } + }, + { + Sid = "PassAgentRoles" + Effect = "Allow" + Action = "iam:PassRole" + Resource = ["arn:aws:iam::123456789012:role/agent-task", "arn:aws:iam::123456789012:role/agent-exec"] + Condition = { StringEquals = { "iam:PassedToService" = "ecs-tasks.amazonaws.com" } } + }, + ] + } + error_message = "the updater's task role must be able to deploy to the agent's service and nothing else" + } + assert { + condition = aws_iam_role_policy.updater_deploy_agent[0].role == aws_iam_role.updater_task[0].id + error_message = "the deploy policy belongs to the updater's task role" + } + # Stated separately, so a broadened PassRole fails with its own message. + assert { + condition = toset(flatten([ + for st in jsondecode(aws_iam_role_policy.updater_deploy_agent[0].policy).Statement : st.Resource + if st.Action == "iam:PassRole" + ])) == toset([aws_iam_role.task.arn, aws_iam_role.execution.arn]) + error_message = "iam:PassRole must be limited to the agent's task and execution roles" + } + assert { + condition = length(aws_iam_role.updater_task[0].name_prefix) <= 38 && length(aws_iam_role.updater_execution[0].name_prefix) <= 38 + error_message = "the updater's role prefixes must fit IAM's 38-character name_prefix limit" + } + assert { + condition = output.updater_service_name == "orders-db-updater" && output.updater_task_role_arn == "arn:aws:iam::123456789012:role/updater-task" + error_message = "the updater's outputs name its service and task role" + } +} + +# On the development channel the updater says so, and defaults to the +# development image; an explicit image wins over either default. +run "updater_development_channel" { + command = plan + + variables { + runtime_updates = true + updater = true + update_channel = "development" + agent_id = "7d0c2b8e-3f1a-4e57-9a7c-1b2c3d4e5f60" + } + + override_data { + target = data.aws_ecs_service.runtime[0] + values = { task_definition = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db:7" } + } + override_data { + target = data.aws_ecs_task_definition.runtime[0] + values = { container_definitions = "[{\"name\":\"elastio-dbmon-agent\",\"image\":\"agent@sha256:new\"}]" } + } + + assert { + condition = local.updater_container.image == "public.ecr.aws/elastio-development/elastio-database-monitoring-updater:latest" + error_message = "the development updater defaults to the development image" + } + assert { + condition = contains(local.updater_container.environment, { name = "ELASTIO_DBMON_UPDATE_CHANNEL", value = "development" }) + error_message = "the updater must be told its channel" + } + assert { + condition = contains(local.updater_container.environment, { name = "ELASTIO_DBMON_AGENT_ID", value = "7d0c2b8e-3f1a-4e57-9a7c-1b2c3d4e5f60" }) + error_message = "the updater must be given the agent's ID" + } +} + +run "updater_image_override" { + command = plan + + variables { + runtime_updates = true + updater = true + updater_image = "example.com/mirror/updater@sha256:abc" + agent_id = "7d0c2b8e-3f1a-4e57-9a7c-1b2c3d4e5f60" + } + + override_data { + target = data.aws_ecs_service.runtime[0] + values = { task_definition = "arn:aws:ecs:us-east-1:123456789012:task-definition/orders-db:7" } + } + override_data { + target = data.aws_ecs_task_definition.runtime[0] + values = { container_definitions = "[{\"name\":\"elastio-dbmon-agent\",\"image\":\"agent@sha256:new\"}]" } + } + + assert { + condition = local.updater_container.image == "example.com/mirror/updater@sha256:abc" + error_message = "an explicit updater_image must be used as given" + } +} diff --git a/database-monitoring/terraform/updater.tf b/database-monitoring/terraform/updater.tf new file mode 100644 index 0000000..bd7fb07 --- /dev/null +++ b/database-monitoring/terraform/updater.tf @@ -0,0 +1,265 @@ +# The deployment updater, as a second ECS service in the agent's cluster. +# +# Fargate has no host to run the updater on, so it runs as its own task. It +# asks Elastio for update work with the agent's API key, resolves and verifies +# the image Elastio names, and deploys it by registering a new revision of the +# agent's task definition with that image and pointing the agent's service at +# it. That is all it may do in AWS: its task role can read and update the +# agent's service, register revisions of the agent's task family, and pass the +# agent's two roles to ECS -- and nothing else. + +variable "updater" { + description = "Run Elastio's deployment updater as a second ECS service in the agent's cluster, so an agent update started from Elastio is applied to this service. Requires runtime_updates = true, so a later apply keeps the image the updater deployed. Enable both after the first apply: runtime_updates reads the service, which must exist." + type = bool + default = false + + validation { + condition = !var.updater || var.runtime_updates + error_message = "updater = true needs runtime_updates = true. Without it, the next apply would put back the module's image over the one the updater deployed. Both need the agent's service to exist: enable them after the first apply." + } +} + +variable "updater_image" { + description = "Deployment updater image, used when updater is true. The default follows update_channel: the updater released with this module version for production, the latest development build for development." + type = string + default = null +} + +variable "update_channel" { + description = "Release channel the updater installs agents from: production or development." + type = string + default = "production" + + validation { + condition = contains(["production", "development"], var.update_channel) + error_message = "update_channel must be production or development." + } +} + +variable "agent_id" { + description = "The agent's ID in Elastio, a UUID: the agent logs it on every start as \"registered as \" in this module's log group. Required when updater = true: the updater cannot discover it, and without it it runs but never updates the agent." + type = string + default = "" + + validation { + condition = !var.updater || can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.agent_id)) + error_message = "updater = true needs agent_id: the agent's ID in Elastio, a UUID the agent logs on start as \"registered as \". Without it the updater service runs and costs money but never updates the agent." + } +} + +locals { + updater = var.updater + + updater_default_image = { + production = "public.ecr.aws/elastio/elastio-database-monitoring-updater:0.1.10" + development = "public.ecr.aws/elastio-development/elastio-database-monitoring-updater:latest" + } + updater_image = coalesce(var.updater_image, local.updater_default_image[var.update_channel]) + + # ECS names are at most 255 characters; leave room for the suffix. + updater_ecs_name = "${substr(local.ecs_name, 0, 247)}-updater" + + # The agent's task family, any revision. Registering a revision and pointing + # the service at one are both limited to it. + agent_task_family_arn = "${aws_ecs_task_definition.this.arn_without_revision}:*" + + updater_container = { + name = "${local.prefix}-updater" + image = local.updater_image + essential = true + + environment = [ + { name = "ELASTIO_DBMON_SERVER_URL", value = var.server_url }, + { name = "ELASTIO_DBMON_AGENT_ID", value = var.agent_id }, + { name = "ELASTIO_DBMON_DEPLOYMENT", value = "ecs" }, + { name = "ELASTIO_DBMON_UPDATE_CHANNEL", value = var.update_channel }, + { name = "ELASTIO_DBMON_ECS_CLUSTER", value = aws_ecs_cluster.this.name }, + { name = "ELASTIO_DBMON_ECS_SERVICE", value = aws_ecs_service.this.name }, + { name = "ELASTIO_DBMON_ECS_CONTAINER", value = local.agent_container_base.name }, + ] + + # The agent's own API key, from the agent's secret: the updater acts for + # this agent and no other. + secrets = [ + { name = "ELASTIO_DBMON_API_KEY", valueFrom = aws_secretsmanager_secret.api_key.arn }, + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.this.name + "awslogs-region" = local.region + "awslogs-stream-prefix" = "updater" + } + } + } +} + +# The updater's execution role: pull the image, write the log, and read the +# agent's API key -- not the database URL, not the hash key. + +resource "aws_iam_role" "updater_execution" { + count = local.updater ? 1 : 0 + + # "elastio-dbmon-" (14) + 16 + "-uexec-" (7) = 37, within IAM's 38. + name_prefix = "${local.prefix}-${local.short_name}-uexec-" + description = "ECS task execution role of the Elastio database monitoring agent's updater" + assume_role_policy = local.ecs_tasks_assume_role_policy + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "updater_execution" { + count = local.updater ? 1 : 0 + + role = aws_iam_role.updater_execution[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "updater_read_api_key" { + count = local.updater ? 1 : 0 + + name = "read-${local.prefix}-api-key" + role = aws_iam_role.updater_execution[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = aws_secretsmanager_secret.api_key.arn + }, + ] + }) +} + +# The updater's task role: what the updater process calls AWS with. + +resource "aws_iam_role" "updater_task" { + count = local.updater ? 1 : 0 + + # "elastio-dbmon-" (14) + 16 + "-utask-" (7) = 37, within IAM's 38. + name_prefix = "${local.prefix}-${local.short_name}-utask-" + description = "ECS task role of the Elastio database monitoring agent's updater: deploys an agent image to the agent's service, nothing else" + assume_role_policy = local.ecs_tasks_assume_role_policy + tags = var.tags +} + +resource "aws_iam_role_policy" "updater_deploy_agent" { + count = local.updater ? 1 : 0 + + name = "deploy-${local.prefix}-agent" + role = aws_iam_role.updater_task[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadAgentService" + Effect = "Allow" + Action = "ecs:DescribeServices" + Resource = aws_ecs_service.this.id + }, + { + # ECS does not scope DescribeTaskDefinition to a resource: it is "*" + # or nothing. It reads a definition; it changes nothing. + Sid = "ReadTaskDefinitions" + Effect = "Allow" + Action = "ecs:DescribeTaskDefinition" + Resource = "*" + }, + { + Sid = "RegisterAgentTaskDefinition" + Effect = "Allow" + Action = "ecs:RegisterTaskDefinition" + Resource = local.agent_task_family_arn + }, + { + # Only the agent's service, and only to a revision of the agent's own + # family: never to another family's task definition and its roles. + Sid = "DeployAgentService" + Effect = "Allow" + Action = "ecs:UpdateService" + Resource = aws_ecs_service.this.id + Condition = { + ArnLike = { "ecs:task-definition" = local.agent_task_family_arn } + } + }, + { + # A new revision carries the agent's task and execution roles, and + # registering it passes both. Exactly those two, and only to ECS. + Sid = "PassAgentRoles" + Effect = "Allow" + Action = "iam:PassRole" + Resource = [ + aws_iam_role.task.arn, + aws_iam_role.execution.arn, + ] + Condition = { + StringEquals = { "iam:PassedToService" = "ecs-tasks.amazonaws.com" } + } + }, + ] + }) +} + +# The smallest Fargate task, on ARM like the agent. The updater keeps no +# state: each update is a job Elastio hands it. + +resource "aws_ecs_task_definition" "updater" { + count = local.updater ? 1 : 0 + + family = local.updater_ecs_name + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = 256 + memory = 512 + execution_role_arn = aws_iam_role.updater_execution[0].arn + task_role_arn = aws_iam_role.updater_task[0].arn + + runtime_platform { + cpu_architecture = "ARM64" + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([local.updater_container]) + + tags = var.tags +} + +# One updater, and never two: two would take the same update job. Minimum +# healthy 0 / maximum 100 stops the old task before starting the new one, as +# for the agent. Same subnets and security groups as the agent: it needs +# outbound HTTPS to Elastio, the registry and the ECS API, and no inbound. + +resource "aws_ecs_service" "updater" { + count = local.updater ? 1 : 0 + + name = local.updater_ecs_name + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.updater[0].arn + launch_type = "FARGATE" + desired_count = 1 + + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + enable_execute_command = false + + network_configuration { + subnets = var.subnet_ids + security_groups = var.security_group_ids + assign_public_ip = var.assign_public_ip + } + + tags = var.tags + + # As for the agent: the task must not start before it may read its secret + # and deploy, and destroy must not remove the permissions under it. + depends_on = [ + aws_iam_role_policy_attachment.updater_execution, + aws_iam_role_policy.updater_read_api_key, + aws_iam_role_policy.updater_deploy_agent, + aws_secretsmanager_secret_version.api_key, + ] +}