diff --git a/integrations/terraform-modules/Makefile b/integrations/terraform-modules/Makefile index 5ca73ec44ce..3356a1cce75 100644 --- a/integrations/terraform-modules/Makefile +++ b/integrations/terraform-modules/Makefile @@ -61,6 +61,7 @@ release: build PLUGIN_TYPE := terraform-module # This list controls which modules are actually published. +# TODO(gavin): publish teleport/container-service/aws after updating the promote-terraform cmd in teleport.e to handle hyphens in module names PUBLISHED_TF_MODULES := \ teleport/discovery/aws \ teleport/discovery/azure \ @@ -99,6 +100,13 @@ tfclean: -or -name 'terraform.tfstate*' \ \) -exec rm -rf {} \+ +# +# update-version updates Terraform source code that depends on the current release version. +# +.PHONY: update-version +update-version: + ./gen/update-version.sh $(VERSION) + .PHONY: create-module create-module: ifndef MODULE diff --git a/integrations/terraform-modules/gen/update-version.sh b/integrations/terraform-modules/gen/update-version.sh new file mode 100755 index 00000000000..0da1da42c4d --- /dev/null +++ b/integrations/terraform-modules/gen/update-version.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat <&2 +Usage: update-version.sh + +Updates source to match the current version. + +Examples: + version.sh 19.0.0 + +EOF +} + +if [[ $# -ne 1 ]]; then + usage + exit 2 +fi + +VERSION="${1}" + +if [[ -z "${VERSION}" ]]; then + usage + echo "error: must be non-empty" >&2 + exit 2 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cat > "${script_dir}"/../teleport/container-service/aws/teleport_version_variable.tf < +- [Configure Teleport Terraform Provider](https://goteleport.com/docs/configuration/terraform-provider/) +- [Configure AWS Terraform provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) + +## Examples + +Refer to the [examples](./examples) for example usage of this module. + +## How to get help + +If you're having trouble, check out our [GitHub Discussions](https://github.com/gravitational/teleport/discussions). + +For bugs related to this code, please [open an issue](https://github.com/gravitational/teleport/issues/new/choose). + + +## Requirements + +| Name | Version | +| ---- | ------- | +| terraform | >= 1.5.7 | +| aws | >= 6.0 | +| http | >= 3.0 | + +## Providers + +| Name | Version | +| ---- | ------- | +| aws | >= 6.0 | +| http | >= 3.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cloudwatch_log_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_ecs_cluster.teleport_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource | +| [aws_ecs_service.teleport_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_task_definition.teleport_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_iam_role.ecs_execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.ecs_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.ecs_execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ecs_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_security_group.teleport_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_vpc_security_group_egress_rule.allow_all_outbound_from_teleport_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_caller_identity.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.ecs_execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ecs_execution_trust](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ecs_task_trust](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | +| [aws_region.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | +| [aws_subnet.teleport_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet) | data source | +| [http_http.managed_updates](https://registry.terraform.io/providers/hashicorp/http/latest/docs/data-sources/http) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| apply\_aws\_tags | Additional AWS tags to apply to all created AWS resources. | `map(string)` | `{}` | no | +| assign\_public\_ip | Whether to assign public IP addresses to Teleport agent ECS tasks. If this is set to true, then var.ecs\_service\_subnets must be public subnets (route to an internet gateway). Otherwise, var.ecs\_service\_subnets must be private subnets (route to a NAT gateway). | `bool` | `false` | no | +| create | Toggle creation of all resources. | `bool` | `true` | no | +| create\_security\_group | Whether to create a security group for the Teleport agent ECS tasks. | `bool` | `true` | no | +| ecs\_cluster\_name | Name of the ECS cluster. | `string` | `"teleport"` | no | +| ecs\_service\_name | Name of the ECS service. | `string` | `"teleport-service"` | no | +| ecs\_service\_subnets | Subnet IDs where the Teleport agent will be deployed. If var.assign\_public\_ip is true, then all of these subnets must be public subnets (route to an internet gateway). If var.assign\_public\_ip is false, then all of these subnets must be private subnets (route to a NAT gateway). | `list(string)` | n/a | yes | +| ecs\_task\_cloudwatch\_log\_group\_name | Name for the ECS task CloudWatch log group. | `string` | `"ecs-teleport"` | no | +| ecs\_task\_cloudwatch\_log\_group\_region | AWS region for the ECS task CloudWatch log group. Defaults to the AWS provider region. | `string` | `null` | no | +| ecs\_task\_cloudwatch\_log\_group\_retention\_days | Number of days to retain logs in the ECS task CloudWatch log group. | `number` | `30` | no | +| ecs\_task\_cloudwatch\_log\_group\_skip\_destroy | Whether to preserve the ECS task CloudWatch log group when destroying module resources. Set to true if you do not wish the log group (and any logs it may contain) to be deleted at destroy time, and instead just remove the log group from the Terraform state. | `bool` | `false` | no | +| ecs\_task\_cpu | Number of cpu units used by the ECS task. | `string` | `"2048"` | no | +| ecs\_task\_desired\_count | Desired number of Teleport ECS tasks to run. | `number` | `2` | no | +| ecs\_task\_force\_new\_deployment | Set to true to force the ECS service to redeploy tasks without configuration changes. | `bool` | `false` | no | +| ecs\_task\_memory | Amount (in MiB) of memory used by the ECS task. | `string` | `"4096"` | no | +| ecs\_task\_name | Name of the ECS task. | `string` | `"teleport-agent"` | no | +| ecs\_task\_role\_inline\_policy | Optional JSON policy document to attach inline to the ECS task IAM role. | `string` | `null` | no | +| environment\_vars | Environment variables to set on the Teleport ECS container. | `map(string)` | `{}` | no | +| managed\_updates\_enabled | Whether to resolve the Teleport container version from the configured Managed Updates endpoint when applying this module. | `bool` | `false` | no | +| managed\_updates\_group | Update group to query through the v2 Managed Updates endpoint. | `string` | `"default"` | no | +| security\_group\_ids | Additional security group IDs to attach to the Teleport agent ECS tasks. | `list(string)` | `[]` | no | +| teleport\_config | Teleport configuration. Write the configuration using native Terraform syntax. Warning: sensitive data, such as static join tokens, is visible to anyone who can read the task definition. | `any` | n/a | yes | +| teleport\_container\_image | Container image used for Teleport ECS tasks. | `string` | `"public.ecr.aws/gravitational/teleport-ent-distroless"` | no | +| teleport\_version | The version of Teleport to deploy. Generally, the version of Teleport should be controlled by using the appropriate version of this module. This variable is intended for development usage. | `string` | `"19.0.0-prealpha.2"` | no | +| vpc\_id | VPC ID where the Teleport agent will be deployed. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| ecs\_execution\_role\_arn | The ARN of the execution IAM role for the Teleport ECS task. | +| ecs\_execution\_role\_name | The name of the execution IAM role for the Teleport ECS task. | +| ecs\_task\_role\_arn | The ARN of the task IAM role for the Teleport agent ECS task. | +| ecs\_task\_role\_name | The name of the task IAM role for the Teleport agent ECS task. | +| security\_group\_id | Security group ID created for the Teleport agent ECS service. | +| teleport\_provision\_token\_allow\_aws\_arn | A value that can be used with a Teleport IAM join token to allow the ECS cluster to join the Teleport cluster using its IAM credentials. | + diff --git a/integrations/terraform-modules/teleport/container-service/aws/aws_cloudwatch_log_group.tf b/integrations/terraform-modules/teleport/container-service/aws/aws_cloudwatch_log_group.tf new file mode 100644 index 00000000000..2054907dc11 --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/aws_cloudwatch_log_group.tf @@ -0,0 +1,12 @@ +resource "aws_cloudwatch_log_group" "this" { + count = var.create ? 1 : 0 + + name = var.ecs_task_cloudwatch_log_group_name + region = coalesce( + var.ecs_task_cloudwatch_log_group_region, + one(data.aws_region.this[*].name), + ) + retention_in_days = var.ecs_task_cloudwatch_log_group_retention_days + skip_destroy = var.ecs_task_cloudwatch_log_group_skip_destroy + tags = var.apply_aws_tags +} diff --git a/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_cluster.tf b/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_cluster.tf new file mode 100644 index 00000000000..196d05ccd67 --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_cluster.tf @@ -0,0 +1,10 @@ +################################################################################ +# ECS Cluster +################################################################################ + +resource "aws_ecs_cluster" "teleport_agent" { + count = var.create ? 1 : 0 + + name = var.ecs_cluster_name + tags = var.apply_aws_tags +} diff --git a/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_service.tf b/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_service.tf new file mode 100644 index 00000000000..abdcf5fe4c3 --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_service.tf @@ -0,0 +1,71 @@ +locals { + create_security_group = var.create && var.create_security_group +} + +################################################################################ +# ECS Service +################################################################################ + +resource "aws_ecs_service" "teleport_agent" { + count = var.create ? 1 : 0 + + # Roles are usable by ECS only once their inline policies are + # attached; the role-ARN reference alone does not order this. + depends_on = [ + aws_iam_role_policy.ecs_execution, + aws_iam_role_policy.ecs_task, + ] + + cluster = one(aws_ecs_cluster.teleport_agent[*].id) + desired_count = var.ecs_task_desired_count + force_new_deployment = var.ecs_task_force_new_deployment + launch_type = "FARGATE" + name = var.ecs_service_name + tags = var.apply_aws_tags + task_definition = one(aws_ecs_task_definition.teleport_agent[*].arn) + + network_configuration { + # Public subnets: tasks need a public IP for IGW egress. + # Private subnets: tasks egress via the NAT route instead. + assign_public_ip = var.assign_public_ip + security_groups = concat(aws_security_group.teleport_agent[*].id, var.security_group_ids) + subnets = var.ecs_service_subnets + } + + lifecycle { + precondition { + condition = var.create_security_group || length(var.security_group_ids) > 0 + error_message = "At least one security group is required. Set var.create_security_group to true or provide security group IDs via var.security_group_ids." + } + + precondition { + condition = length(var.ecs_service_subnets) > 0 + error_message = "At least one subnet must be provided for the Teleport agent ECS deployment." + } + + precondition { + condition = alltrue([ + for subnet in data.aws_subnet.teleport_agent : + subnet.vpc_id == var.vpc_id + ]) + error_message = "Each Teleport agent subnet must belong to the configured vpc_id." + } + } +} + +resource "aws_security_group" "teleport_agent" { + count = local.create_security_group ? 1 : 0 + + description = "Teleport agent security group for ${var.vpc_id}." + name_prefix = var.ecs_service_name + tags = var.apply_aws_tags + vpc_id = var.vpc_id +} + +resource "aws_vpc_security_group_egress_rule" "allow_all_outbound_from_teleport_agent" { + count = local.create_security_group ? 1 : 0 + + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + security_group_id = one(aws_security_group.teleport_agent[*].id) +} diff --git a/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_task_definition.tf b/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_task_definition.tf new file mode 100644 index 00000000000..a65a397f3be --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/aws_ecs_task_definition.tf @@ -0,0 +1,72 @@ +################################################################################ +# Task definition +################################################################################ + +locals { + managed_updates_proxy_addr = try(var.teleport_config.teleport.proxy_server, "") + managed_updates_version = ( + length(data.http.managed_updates) == 1 + ? jsondecode(data.http.managed_updates[0].response_body).auto_update.agent_version + : null + ) + teleport_version = trimprefix( + trimspace( + coalesce( + local.managed_updates_version, + var.teleport_version + ), + ), + "v" + ) +} + +resource "aws_ecs_task_definition" "teleport_agent" { + count = var.create ? 1 : 0 + + container_definitions = jsonencode([ + { + command = [ + # rewrite SIGTERM (15) to SIGQUIT (3) so ECS stop signal triggers graceful Teleport shutdown + "--rewrite", + "15:3", + "--", + "teleport", + "start", + "--config-string", + base64encode(yamlencode(var.teleport_config)), + ] + entryPoint = ["/usr/bin/dumb-init"] + environment = [ + for name in sort(keys(var.environment_vars)) : { + name = name + value = var.environment_vars[name] + } + ] + image = "${var.teleport_container_image}:${local.teleport_version}" + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = one(aws_cloudwatch_log_group.this[*].name) + "awslogs-region" = one(aws_cloudwatch_log_group.this[*].region) + "awslogs-stream-prefix" = "${var.ecs_cluster_name}-${var.ecs_service_name}" + } + } + name = "teleport" + } + ]) + cpu = var.ecs_task_cpu + execution_role_arn = one(aws_iam_role.ecs_execution[*].arn) + family = var.ecs_task_name + memory = var.ecs_task_memory + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + tags = var.apply_aws_tags + task_role_arn = one(aws_iam_role.ecs_task[*].arn) + + lifecycle { + precondition { + condition = !var.managed_updates_enabled || local.managed_updates_proxy_addr != "" + error_message = "Managed Updates require teleport.proxy_server in teleport_config." + } + } +} diff --git a/integrations/terraform-modules/teleport/container-service/aws/aws_task_execution_role.tf b/integrations/terraform-modules/teleport/container-service/aws/aws_task_execution_role.tf new file mode 100644 index 00000000000..b8d0e9fe457 --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/aws_task_execution_role.tf @@ -0,0 +1,70 @@ +################################################################################ +# Execution role +################################################################################ + +resource "aws_iam_role" "ecs_execution" { + count = var.create ? 1 : 0 + + assume_role_policy = one(data.aws_iam_policy_document.ecs_execution_trust[*].json) + description = "Execution role used by the Teleport ECS agent task." + name_prefix = "${var.ecs_cluster_name}-exec" + tags = var.apply_aws_tags +} + +data "aws_iam_policy_document" "ecs_execution_trust" { + count = var.create ? 1 : 0 + + statement { + actions = ["sts:AssumeRole"] + + effect = "Allow" + + condition { + test = "StringEquals" + values = [one(data.aws_caller_identity.this[*].account_id)] + variable = "aws:SourceAccount" + } + + condition { + test = "ArnLike" + values = [ + format( + "arn:%s:ecs:%s:%s:*", + one(data.aws_partition.this[*].partition), + one(data.aws_region.this[*].name), + one(data.aws_caller_identity.this[*].account_id), + ), + ] + variable = "aws:SourceArn" + } + + principals { + identifiers = ["ecs-tasks.amazonaws.com"] + type = "Service" + } + } +} + +resource "aws_iam_role_policy" "ecs_execution" { + count = var.create ? 1 : 0 + + name = "ecs-execution" + policy = one(data.aws_iam_policy_document.ecs_execution[*].json) + role = one(aws_iam_role.ecs_execution[*].id) +} + +data "aws_iam_policy_document" "ecs_execution" { + count = var.create ? 1 : 0 + + statement { + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + effect = "Allow" + resources = [ + one(aws_cloudwatch_log_group.this[*].arn), + "${one(aws_cloudwatch_log_group.this[*].arn)}:*", + ] + } +} diff --git a/integrations/terraform-modules/teleport/container-service/aws/aws_task_role.tf b/integrations/terraform-modules/teleport/container-service/aws/aws_task_role.tf new file mode 100644 index 00000000000..924c43c5cfc --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/aws_task_role.tf @@ -0,0 +1,54 @@ +################################################################################ +# Task role +################################################################################ + +resource "aws_iam_role" "ecs_task" { + count = var.create ? 1 : 0 + + assume_role_policy = one(data.aws_iam_policy_document.ecs_task_trust[*].json) + description = "Task role used by the Teleport ECS agent task." + name_prefix = "${var.ecs_cluster_name}-task" + tags = var.apply_aws_tags +} + +resource "aws_iam_role_policy" "ecs_task" { + count = var.create && var.ecs_task_role_inline_policy != null ? 1 : 0 + + name = "ecs-task" + policy = var.ecs_task_role_inline_policy + role = one(aws_iam_role.ecs_task[*].id) +} + +data "aws_iam_policy_document" "ecs_task_trust" { + count = var.create ? 1 : 0 + + statement { + actions = ["sts:AssumeRole"] + + effect = "Allow" + + condition { + test = "StringEquals" + values = [one(data.aws_caller_identity.this[*].account_id)] + variable = "aws:SourceAccount" + } + + condition { + test = "ArnLike" + values = [ + format( + "arn:%s:ecs:%s:%s:*", + one(data.aws_partition.this[*].partition), + one(data.aws_region.this[*].name), + one(data.aws_caller_identity.this[*].account_id), + ), + ] + variable = "aws:SourceArn" + } + + principals { + identifiers = ["ecs-tasks.amazonaws.com"] + type = "Service" + } + } +} diff --git a/integrations/terraform-modules/teleport/container-service/aws/data.tf b/integrations/terraform-modules/teleport/container-service/aws/data.tf new file mode 100644 index 00000000000..2c9ecdb597e --- /dev/null +++ b/integrations/terraform-modules/teleport/container-service/aws/data.tf @@ -0,0 +1,47 @@ +data "aws_caller_identity" "this" { + count = var.create ? 1 : 0 +} + +data "aws_region" "this" { + count = var.create ? 1 : 0 +} + +data "aws_partition" "this" { + count = var.create ? 1 : 0 +} + +data "aws_subnet" "teleport_agent" { + count = var.create ? length(var.ecs_service_subnets) : 0 + + id = var.ecs_service_subnets[count.index] +} + +data "http" "managed_updates" { + count = var.create && var.managed_updates_enabled ? 1 : 0 + + url = format( + "https://%s/webapi/find?group=%s", + local.managed_updates_proxy_addr, + urlencode(coalesce(var.managed_updates_group, "default")), + ) + + lifecycle { + precondition { + condition = !var.managed_updates_enabled || local.managed_updates_proxy_addr != "" + error_message = "Managed Updates require teleport.proxy_server in teleport_config." + } + + postcondition { + condition = self.status_code == 200 && can( + regex( + "^v?[0-9]+\\.[0-9]+\\.[0-9]+.*", + trimspace(jsondecode(self.response_body).auto_update.agent_version), + ) + ) + error_message = < api/version.go @printf $(GITREF_GO) | gofmt > gitref.go + $(MAKE) -C integrations/terraform-modules update-version VERSION=$(VERSION) # helm-version automatically updates the versions of Helm charts to match the version set in the Makefile, # so that chart versions are also kept in sync when the Teleport version is updated for a release.