diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..365df2b --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,42 @@ +FROM mcr.microsoft.com/vscode/devcontainers/base:jammy + +# install aws +RUN SYSTEM_ARCH=$(uname -m) \ + && curl "https://awscli.amazonaws.com/awscli-exe-linux-${SYSTEM_ARCH}.zip" -o "awscliv2.zip" \ + && unzip awscliv2.zip \ + && aws/install \ + && aws --version \ + && rm -rf aws + +# install terraform +ENV TERRAFORM_VERSION=1.5.1 +ENV TF_PLUGIN_CACHE_DIR=$HOME/.terraform.d/plugin-cache +RUN mkdir -p $TF_PLUGIN_CACHE_DIR +RUN SYSTEM_ARCH=$(dpkg --print-architecture) \ + && curl -OL https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${SYSTEM_ARCH}.zip \ + && unzip terraform_${TERRAFORM_VERSION}_linux_${SYSTEM_ARCH}.zip \ + && mv terraform /usr/local/bin/ \ + && terraform version \ + && rm terraform_${TERRAFORM_VERSION}_linux_${SYSTEM_ARCH}.zip + +# install tflint +RUN curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash + +# install docker +COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ + +# install pip +RUN apt-get update +RUN apt-get install -y \ + python3-pip \ + shellcheck + +# install python packages +RUN python3 -m pip install \ + boto3 \ + black + +# verify installs +RUN terraform --version \ + && aws --version \ + && docker --version diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d386d14 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,48 @@ +{ + "name": "Terraform", + "dockerFile": "Dockerfile", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2.0.1": {} + }, + "mounts": [ + "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached" + ], + "containerEnv": { + "TF_PLUGIN_CACHE_DIR": "${containerWorkspaceFolder}/.devcontainer/tmp/.terraform.d/" + }, + "customizations": { + "vscode": { + "settings": { + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "editor.inlineSuggest.enabled": true, + "terminal.integrated.shell.linux": "/bin/bash", + "python.defaultInterpreterPath": "/usr/bin/python3", + "[markdown]": { + "editor.rulers": [ + 80 + ] + }, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + } + }, + "extensions": [ + "darkriszty.markdown-table-prettify", + "editorconfig.editorconfig", + "github.copilot", + "github.copilot-chat", + "github.vscode-github-actions", + "github.vscode-pull-request-github", + "hashicorp.terraform", + "ms-azuretools.vscode-docker", + "ms-python.black-formatter", + "timonwong.shellcheck", + "VisualStudioExptTeam.vscodeintellicode" + ] + } + } +} diff --git a/.devcontainer/tmp/.terraform.d/.gitkeep b/.devcontainer/tmp/.terraform.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0cd22c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[{Dockerfile,Dockerfile.*}] +indent_size = 4 +tab_width = 4 + +[{Makefile,makefile,GNUmakefile}] +indent_style = tab +indent_size = 4 + +[Makefile.*] +indent_style = tab +indent_size = 4 + +[**/*.{go,mod,sum}] +indent_style = tab +indent_size = unset + +[**/*.py] +indent_size = 4 diff --git a/.github/.dependabot.yml b/.github/.dependabot.yml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/.dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1f3031f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Bump Version + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: minor + custom_release_rules: bug:patch:Fixes,chore:patch:Chores,docs:patch:Documentation,feat:minor:Features,refactor:minor:Refactors,test:patch:Tests,ci:patch:Development,dev:patch:Development + - name: Create Release + uses: ncipollo/release-action@v1.12.0 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} diff --git a/.github/workflows/semantic-check.yml b/.github/workflows/semantic-check.yml new file mode 100644 index 0000000..2e5d44f --- /dev/null +++ b/.github/workflows/semantic-check.yml @@ -0,0 +1,26 @@ +name: semantic-check +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + contents: read + pull-requests: read + +jobs: + main: + name: Semantic Commit Message Check + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - uses: amannn/action-semantic-pull-request@v5.2.0 + name: Check PR for Semantic Commit Message + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + requireScope: false + validateSingleCommit: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..679d22b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: test + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Terraform Setup + run: | + terraform init + - name: Lint Terraform + uses: reviewdog/action-tflint@master + with: + github_token: ${{ secrets.github_token }} + filter_mode: "nofilter" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d6beec --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# .gitignore + +# terraform files +.terraform.lock.hcl +.terraform.tfstate.lock.info +*.tfvars.hcl +*.tfstate +*.tfstate.*.backup +*.tfstate.backup +*.tfplan +*.terraform/ +*.tfvars +.grunt + +# node.js / typescript +node_modules +npm-debug.log +yarn-error.log +dist +out +*.tsbuildinfo + +# logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# runtime data +pids +*.pid +*.seed +*.pid.lock + +# coverage directories +coverage +lib-cov + +# docker files +*.tar +dockerfile.*.bak + +# general +tmp/ +!**/.gitkeep +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# ides +.vscode +.idea +*.swp +*.swo + +# opa +bundle.tar.gz + +# lifecycle +**/lifecycle* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f5aeffa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing + +We welcome contributions to the project. This document provides information and +guidelines for contributing. + +## Development Environment + +This repository includes a configuration for a development container using the +[VS Code Remote - Containers extension](https://code.visualstudio.com/docs/remote/containers). +This setup allows you to develop within a Docker container that already has all +the necessary tools and dependencies installed. + +The development container is based on Ubuntu 22.04 (Jammy) and includes the +following tools: + +- AWS CLI +- Python v3.8 +- Python Packages: `boto3`, `black` +- Docker CLI +- Shellcheck +- Terraform + +### Prerequisites + +- [Docker](https://www.docker.com/products/docker-desktop) installed on your + local machine. +- [Visual Studio Code](https://code.visualstudio.com/) installed on your + local machine. +- [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) + for Visual Studio Code. + +### Usage + +1. Clone and open this repository: + + ```bash + git clone https://github.com/cruxstack/terraform-aws-ecs-cluster.git + code terraform-aws-ecs-cluster + ``` + +2. When prompted to "Reopen in Container", click "Reopen in Container". This + will start building the Docker image for the development container. If you're + not prompted, you can open the Command Palette (F1 or Ctrl+Shift+P), and run + the "Remote-Containers: Reopen Folder in Container" command. + +3. After the development container is built and started, you can use the + Terminal in Visual Studio Code to interact with the container. All commands + you run in the Terminal will be executed inside the container. + +### Troubleshooting + +If you encounter any issues while using the development container, you can try +rebuilding the container. To do this, open the Command Palette and run the +"Remote-Containers: Rebuild Container" command. + +## Contribution Guidelines + +We appreciate your interest in contributing to the project. Here are some +guidelines to help ensure your contributions are accepted. + +### Issues + +- Use the GitHub issue tracker to report bugs or propose new features. +- Before submitting a new issue, please search to make sure it has not already + been reported. If it has, add a comment to the existing issue instead of + creating a new one. +- When reporting a bug, include as much detail as you can. Include the version + of the module you're using, what you expected to happen, what actually + happened, and steps to reproduce the bug. + +### Pull Requests + +- Submit your changes as a pull request. +- All pull requests should be associated with an issue. If your change isn't + associated with an existing issue, please create one before submitting a pull + request. +- In your pull request, include a summary of the changes, the issue number it + resolves, and any additional information that might be helpful for + understanding your changes. +- Make sure your changes do not break any existing functionality. If your + changes require updates to existing tests or the addition of new ones, include + those in your pull request. +- Follow the existing code style. We use a linter to maintain code quality, so + make sure your changes pass the linter checks. + +Thank you for your contributions! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..895c8d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 CruxStack LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aa0fdaf --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +PROJ_ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) + +# allows args to pass to run-cmd example: make run-cmd echo "hello world" +ifeq (run-cmd,$(firstword $(MAKECMDGOALS))) + RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) + $(eval $(RUN_ARGS):;@:) +endif + +all: deps build + @exit 0 + +deps: + @exit 0 + +build: + @exit 0 + +clean: + @find . -type d -name "dist" -exec rm -rf {} + + @find . -type d -name ".terraform" -exec rm -rf {} + + @find . -type d -name ".terraform.d" -exec rm -rf {} + + @find . -type d -name ".tfstate" -exec rm -rf {} + + @find . -type d -name ".tfstate.backup" -exec rm -rf {} + + @touch .devcontainer/.terraform.d/.gitkeep || true diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4d87ca --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Terraform Module: ECS Cluster + +This Terraform module provides a comprehensive solution for creating and +managing an AWS Elastic Container Service (ECS) cluster. Key features include +support for self-hosted nodes, both spot and on-demand, with configurable +auto-scaling policies. The module also supports log retention configuration, +instance customization, and monitoring through collected metrics. It leverages +the `cloudposse/label/null` module for consistent naming and tagging of +resources. Whether you need a simple ECS cluster or a complex, self-hosted +environment, this module offers flexibility and ease of use to meet your +container orchestration needs. + +## Features + +- **ECS Cluster Creation**: Provides ECS cluster with optional self-hosted nodes + (spot and on-demand). +- **Auto-Scaling Configuration**: Supports auto-scaling with customizable count + range. +- **Instance Configuration**: Allows configuration of instance sizes and user + data scripts. +- **Capacity Providers**: Configures ECS capacity providers. + +## Usage + +Deploy it using the block below. + +```hcl +module "ecs_cluster" { + source = "cruxstack/ecs-cluster/aws" + version = "x.x.x" + + vpc_id = "vpc-00000000000000" + vpc_subnet_ids = ["subnet-33333333333333", "subnet-44444444444444444", "subnet-55555555555555555"] +} +``` + +## Inputs + +In addition to the variables documented below, this module includes several +other optional variables (e.g., `name`, `tags`, etc.) provided by the +`cloudposse/label/null` module. Please refer to its [documentation](https://registry.terraform.io/modules/cloudposse/label/null/latest) +for more details on these variables. + +| Name | Description | Type | Default | Required | +|-----------------------------|-----------------------------------------------------------------------|----------------|-----------------|:--------:| +| `self_hosted` | Enable self-hosted nodes | `bool` | `false` | no | +| `autoscale_count_range` | Autoscale range for spot and on-demand (format "0-3") | `object` | `{}` | no | +| `instance_sizes` | List of instance sizes for the cluster | `list(string)` | `["m5d.large"]` | no | +| `vpc_id` | ID of the VPC for the resources | `string` | n/a | yes | +| `vpc_subnet_ids` | IDs of the subnets in the VPC for the resources | `list(string)` | n/a | yes | +| `vpc_security_groups` | Additional security group IDs for the instances | `list(string)` | `[]` | no | +| `log_retention` | Log retention in days | `number` | `30` | no | +| `aws_account_id` | AWS account ID | `string` | `""` | no | +| `aws_region_name` | AWS region name | `string` | `""` | no | +| `aws_kv_namespace` | AWS Key-Value namespace | `string` | `null` | no | +| `instance_userdata_scripts` | Additional user data scripts for instances | `list(string)` | `[]` | no | +| `collected_metrics` | Configuration of the cluster and instance metrics collection settings | `object` | `{}` | no | +| `iam_policy_arns` | IAM policy ARNs to attach | `list(string)` | `[]` | no | + +### Outputs + +| Name | Description | +|-----------------------|------------------------------------------| +| `security_group_id` | Security group ID for the ECS cluster. | +| `security_group_name` | Security group name for the ECS cluster. | + +## Contributing + +We welcome contributions to this project. For information on setting up a +development environment and how to make a contribution, see [CONTRIBUTING](./CONTRIBUTING.md) +documentation. diff --git a/assets/cloud-init/cloud-config.yaml b/assets/cloud-init/cloud-config.yaml new file mode 100644 index 0000000..86e4176 --- /dev/null +++ b/assets/cloud-init/cloud-config.yaml @@ -0,0 +1,17 @@ +#cloud-config +packages: + - amazon-cloudwatch-agent +package_update: true +write_files: + - path: /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.d/user.json + content: ${cloudwatch_agent_config_encoded} + encoding: base64 + permissions: "0644" + - path: /etc/ecs/ecs.config + content: | + ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION=5m + ECS_NUM_IMAGES_DELETE_PER_CYCLE=50 + ECS_ENABLE_SPOT_INSTANCE_DRAINING=true + ECS_ENGINE_AUTH_TYPE=dockercfg + permissions: "0644" + defer: true diff --git a/assets/cloud-init/cloud_boothook.sh b/assets/cloud-init/cloud_boothook.sh new file mode 100644 index 0000000..d17f356 --- /dev/null +++ b/assets/cloud-init/cloud_boothook.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2207,SC2128,SC2046,SC2206,SC2155,SC2068 + +# --- configurations -------------------------- + +export RAID_NAME=ephemeral_raid +export RAID_DEVICE=/dev/md0 +export RAID_MOUNT_PATH=/var/lib/docker + +# --- functions ------------------------------- + +function list_instance_stores { + if [[ -e /dev/nvme0n1 ]]; then + local instance_stores=($(nvme list | awk '/Instance Storage/ {print $1}')) + else + local OSDEVICE=$(sudo lsblk -o NAME -n | grep -v '[[:digit:]]' | sed "s/^sd/xvd/g") + local BDMURL="http://169.254.169.254/latest/meta-data/block-device-mapping/" + local instance_stores=() + for bd in $(curl -s ${BDMURL}); do + MAPDEVICE=$(curl -s ${BDMURL}/"${bd}"/ | sed "s/^sd/xvd/g"); + if grep -wq "${MAPDEVICE}" <<< "${OSDEVICE}"; then + instance_stores+=(${MAPDEVICE}) + fi + done + fi + echo "${instance_stores[@]}" +} +export -f list_instance_stores + + +function provision_instance_stores { + devices=($(list_instance_stores)) + count=${#devices[@]} + + mkdir -p ${RAID_MOUNT_PATH} + if [[ ${count} -eq 1 ]]; then + mkfs.ext4 "${devices}" + echo "${devices}" ${RAID_MOUNT_PATH} ext4 defaults,noatime 0 2 >> /etc/fstab + elif [[ ${count} -gt 1 ]]; then + mdadm --create --verbose --level=0 ${RAID_DEVICE} --auto=yes --name=${RAID_NAME} --raid-devices="${count}" ${devices[@]} + while [[ $(mdadm -D ${RAID_DEVICE}) != *"State : clean"* ]] && [[ $(mdadm -D $RAID_DEVICE) != *"State : active"* ]]; do + sleep 1 + done + mkfs.ext4 ${RAID_DEVICE} + mdadm --detail --scan >> /etc/mdadm.conf + dracut -H -f /boot/initramfs-$(uname -r).img $(uname -r) + echo ${RAID_DEVICE} ${RAID_MOUNT_PATH} ext4 defaults,noatime 0 2 >> /etc/fstab + fi + mount -a +} +export -f provision_instance_stores + +# --- script ---------------------------------- + +yum install -y mdadm nvme-cli +cloud-init-per once provision_instance_stores provision_instance_stores diff --git a/assets/cloud-init/cloudwatch-agent-config.json b/assets/cloud-init/cloudwatch-agent-config.json new file mode 100644 index 0000000..e8dbee8 --- /dev/null +++ b/assets/cloud-init/cloudwatch-agent-config.json @@ -0,0 +1,91 @@ +{ + "agent": { + "run_as_user": "cwagent" + }, + "metrics": { + "metrics_collected": { + "mem": { + "measurement": [ + "mem_used_percent" + ] + }, + "disk": { + "measurement": [ + "used_percent" + ], + "resources": [ + "*" + ] + } + }, + "append_dimensions": { + "ImageId": "$${aws:ImageId}", + "InstanceId": "$${aws:InstanceId}", + "InstanceType": "$${aws:InstanceType}", + "AutoScalingGroupName": "$${aws:AutoScalingGroupName}" + } + }, + "logs": { + "logs_collected": { + "files": { + "collect_list": [ + { + "file_path": "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/amazon-cloudwatch-agent.log", + "timestamp_format": "%Y-%m-%dT%H:%M:%SZ" + }, + { + "file_path": "/var/log/dmesg", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/dmesg" + }, + { + "file_path": "/var/log/messages", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/messages", + "timestamp_format": "%b %d %H:%M:%S" + }, + { + "file_path": "/var/log/docker", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/docker", + "timestamp_format": "%Y-%m-%dT%H:%M:%S.%f" + }, + { + "file_path": "/var/log/cloud-init.log", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/cloud-init.log", + "multi_line_start_pattern": "\\w+ \\d{2} \\d{2}:\\d{2}:\\d{2} cloud-init\\[[\\w]+]:", + "timestamp_format": "%B %d %H:%M:%S", + "timezone": "UTC" + }, + { + "file_path": "/var/log/cloud-init-output.log", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/cloud-init-output.log", + "multi_line_start_pattern": "Cloud-init v. \\d+.\\d+-\\d+" + }, + { + "file_path": "/var/log/ecs/ecs-init.log", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/ecs/ecs-init.log", + "timestamp_format": "%Y-%m-%dT%H:%M:%SZ" + }, + { + "file_path": "/var/log/ecs/ecs-agent.log.*", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/ecs/ecs-agent.log", + "timestamp_format": "%Y-%m-%dT%H:%M:%SZ" + }, + { + "file_path": "/var/log/ecs/audit.log.*", + "log_group_name": "${cluster_log_group_name}", + "log_stream_name": "/ec2/instance/{instance_id}/ecs/audit.log", + "timestamp_format": "%Y-%m-%dT%H:%M:%SZ" + } + ] + } + } + } +} diff --git a/assets/cloud-init/start_core_services.sh b/assets/cloud-init/start_core_services.sh new file mode 100644 index 0000000..ccca61d --- /dev/null +++ b/assets/cloud-init/start_core_services.sh @@ -0,0 +1,12 @@ +#!/bin/bash -xe + +function cwagent_ctl { + /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl "$@" +} +export -f cwagent_ctl + +chmod 644 /var/log/cloud-init-output.log +chmod 644 /var/log/messages + +cwagent_ctl -a fetch-config -s -m ec2 \ + -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.d/user.json diff --git a/assets/cloud-init/userdata.sh b/assets/cloud-init/userdata.sh new file mode 100644 index 0000000..1852e25 --- /dev/null +++ b/assets/cloud-init/userdata.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e +# shellcheck disable=SC2034,SC2154 + +echo "ECS_CLUSTER=${ecs_cluster_name}" >> /etc/ecs/ecs.config diff --git a/context.tf b/context.tf new file mode 100644 index 0000000..873244c --- /dev/null +++ b/context.tf @@ -0,0 +1,277 @@ +# +# ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf +# and then place it in your Terraform module to automatically get +# Cloud Posse's standard configuration inputs suitable for passing +# to Cloud Posse modules. +# +# curl -sL https://raw.githubusercontent.com/cloudposse/terraform-null-label/master/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "cloudposse/label/null" + version = "0.25.0" # requires Terraform >= 0.13.0 + + enabled = var.enabled + namespace = var.namespace + tenant = var.tenant + environment = var.environment + stage = var.stage + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of cloudposse/terraform-null-label/variables.tf here + +variable "context" { + type = any + default = { + enabled = true + namespace = null + tenant = null + environment = null + stage = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "namespace" { + type = string + default = null + description = "ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique" +} + +variable "tenant" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. A customer identifier, indicating who this instance of a resource is for" +} + +variable "environment" { + type = string + default = null + description = "ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT'" +} + +variable "stage" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} \ No newline at end of file diff --git a/examples/complete/README.md b/examples/complete/README.md new file mode 100644 index 0000000..6f1c257 --- /dev/null +++ b/examples/complete/README.md @@ -0,0 +1,35 @@ +# Example: Complete + +This example demonstrates a complete deployment of the ECS Cluster module, +featuring a self-hosted ECS cluster with configurable instance sizes, log +retention, and metrics collection. The example showcases how to integrate the +module into your existing VPC and subnets, providing a tailored ECS cluster +deployment. + +Usage + +## Usage + +To run this example, provide your own values for the following variables in a +`.terraform.tfvars` file: + +```hcl +vpc_id = "your-vpc-id" +vpc_subnet_ids = ["your-private-subnet-id"] +``` + +## Inputs + + +| Name | Description | Type | Default | Required | +|---------------------|------------------------------------------------------------------------|----------------|----------------------------------------------------|:--------:| +| `instance_sizes` | List of instance sizes (aka types) for the cluster. | `list(string)` | `["t3.nano", "t3.micro", "t3a.nano", "t3a.micro"]` | no | +| `log_retention` | Number of days to retain logs. | `number` | `7` | no | +| `collected_metrics` | Configuration of the cluster and instance metrics collection settings. | `object` | `{}` | no | +| `vpc_id` | ID of the VPC for the resources. | `string` | n/a | yes | +| `vpc_subnet_ids` | IDs of the subnets in the VPC for the resources. | `list(string)` | n/a | yes | + +## Outputs + +_This example does not define any specific outputs at this time._ + diff --git a/examples/complete/context.tf b/examples/complete/context.tf new file mode 100644 index 0000000..873244c --- /dev/null +++ b/examples/complete/context.tf @@ -0,0 +1,277 @@ +# +# ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf +# and then place it in your Terraform module to automatically get +# Cloud Posse's standard configuration inputs suitable for passing +# to Cloud Posse modules. +# +# curl -sL https://raw.githubusercontent.com/cloudposse/terraform-null-label/master/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "cloudposse/label/null" + version = "0.25.0" # requires Terraform >= 0.13.0 + + enabled = var.enabled + namespace = var.namespace + tenant = var.tenant + environment = var.environment + stage = var.stage + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of cloudposse/terraform-null-label/variables.tf here + +variable "context" { + type = any + default = { + enabled = true + namespace = null + tenant = null + environment = null + stage = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "namespace" { + type = string + default = null + description = "ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique" +} + +variable "tenant" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. A customer identifier, indicating who this instance of a resource is for" +} + +variable "environment" { + type = string + default = null + description = "ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT'" +} + +variable "stage" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} \ No newline at end of file diff --git a/examples/complete/main.tf b/examples/complete/main.tf new file mode 100644 index 0000000..c0fa9d0 --- /dev/null +++ b/examples/complete/main.tf @@ -0,0 +1,37 @@ +locals { + name = "tf-example-${random_string.example_random_suffix.result}" + tags = { tf_module = "cruxstack/ecs-cluster/aws", tf_module_example = "complete" } +} + +# ================================================================== example === + +module "ecs_cluster" { + source = "../.." + + self_hosted = true + vpc_id = var.vpc_id + vpc_subnet_ids = var.vpc_subnet_ids + + log_retention = var.log_retention + instance_sizes = var.instance_sizes + collected_metrics = var.collected_metrics + + context = module.example_label.context # not required +} + +# ===================================================== supporting-resources === + +module "example_label" { + source = "cloudposse/label/null" + version = "0.25.0" + + name = local.name + environment = "use1" # us-east-1 + tags = local.tags +} + +resource "random_string" "example_random_suffix" { + length = 6 + special = false + upper = false +} diff --git a/examples/complete/output.tf b/examples/complete/output.tf new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/complete/output.tf @@ -0,0 +1 @@ + diff --git a/examples/complete/provider.tf b/examples/complete/provider.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/examples/complete/provider.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/examples/complete/variables.tf b/examples/complete/variables.tf new file mode 100644 index 0000000..555ab8f --- /dev/null +++ b/examples/complete/variables.tf @@ -0,0 +1,40 @@ +# ============================================================== ecs-cluster === + +variable "instance_sizes" { + type = list(string) + description = "List of instance sizes (aka types) for the cluster." + default = ["t3.nano", "t3.micro", "t3a.nano", "t3a.micro"] +} + +# ------------------------------------------------------------ observability --- + +variable "log_retention" { + type = number + description = "Number of days to retain logs." + default = 7 +} + +variable "collected_metrics" { + type = object({ + cluster = optional(object({ + insights_enabled = optional(bool, false) + }), {}) + instance = optional(object({ + detailed_monitoring = optional(bool, false) + }), {}) + }) + description = "Configuration of the cluster and instance metrics collection settings." + default = {} +} + +# ------------------------------------------------------------------ network --- + +variable "vpc_id" { + description = "ID of the VPC for the resources." + type = string +} + +variable "vpc_subnet_ids" { + description = "IDs of the subnets in the VPC for the resources." + type = list(string) +} diff --git a/examples/complete/version.tf b/examples/complete/version.tf new file mode 100644 index 0000000..d51b0df --- /dev/null +++ b/examples/complete/version.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..0154fc2 --- /dev/null +++ b/main.tf @@ -0,0 +1,371 @@ +locals { + name = coalesce(module.this.name, var.name, "ecs-cluster") + enabled = module.this.enabled + self_hosted_enabled = local.enabled && var.self_hosted + + cluster_node_types = toset(local.self_hosted_enabled ? ["spot", "ondemand"] : []) + + aws_account_id = var.aws_account_id != "" ? var.aws_account_id : try(data.aws_caller_identity.current[0].account_id, "") + aws_region_name = var.aws_region_name != "" ? var.aws_region_name : try(data.aws_region.current[0].name, "") + aws_kv_namespace = trim(coalesce(var.aws_kv_namespace, "ecs-cluster/${module.cluster_label.id}"), "/") +} + +data "aws_caller_identity" "current" { + count = local.enabled && var.aws_account_id == "" ? 1 : 0 +} + +data "aws_region" "current" { + count = local.enabled && var.aws_region_name == "" ? 1 : 0 +} + +# ================================================================== cluster === + +module "cluster_label" { + source = "cloudposse/label/null" + version = "0.25.0" + + name = local.name + context = module.this.context +} + +resource "aws_ecs_cluster" "this" { + count = local.enabled ? 1 : 0 + + name = module.cluster_label.id + + dynamic "setting" { + for_each = var.collected_metrics.cluster.insights_enabled ? [true] : [] + + content { + name = "containerInsights" + value = "enabled" + } + } + + tags = module.cluster_label.tags +} + +# ==================================================================== nodes === + +module "cluster_node_label" { + source = "cloudposse/label/null" + version = "0.25.0" + for_each = local.cluster_node_types + + attributes = [each.key] + tags = { ("${local.aws_kv_namespace}/node-type") : each.key } + context = module.cluster_label.context +} + +resource "aws_autoscaling_group" "this" { + for_each = local.cluster_node_types + + name = module.cluster_node_label[each.key].id + vpc_zone_identifier = var.vpc_subnet_ids + max_instance_lifetime = 86400 + metrics_granularity = var.collected_metrics.autoscaling_group.granularity + enabled_metrics = var.collected_metrics.autoscaling_group.metrics + termination_policies = ["OldestLaunchTemplate", "AllocationStrategy", "Default"] + health_check_type = "EC2" + + desired_capacity = split("-", var.autoscale_count_range[each.key])[0] + min_size = split("-", var.autoscale_count_range[each.key])[0] + max_size = split("-", var.autoscale_count_range[each.key])[1] + + mixed_instances_policy { + instances_distribution { + on_demand_base_capacity = 0 + on_demand_percentage_above_base_capacity = each.key == "ondemand" ? 100 : 0 + } + + launch_template { + launch_template_specification { + launch_template_id = aws_launch_template.this[0].id + version = aws_launch_template.this[0].latest_version + } + + dynamic "override" { + for_each = var.instance_sizes + + content { + instance_type = override.value + weighted_capacity = "1" + } + } + } + } + + instance_refresh { + strategy = "Rolling" + triggers = ["tag"] + + preferences { + min_healthy_percentage = 50 + } + } + + dynamic "tag" { + for_each = merge(module.cluster_node_label[each.key].tags, { Name = module.cluster_node_label[each.key].id }) + + content { + key = tag.key + value = tag.value + propagate_at_launch = true + } + } +} + +# ---------------------------------------------------------- launch-template --- + +data "template_cloudinit_config" "this" { + count = local.self_hosted_enabled ? 1 : 0 + + gzip = true + base64_encode = true + + part { + content_type = "text/cloud-boothook" + content = file("${path.module}/assets/cloud-init/cloud_boothook.sh") + } + + part { + content_type = "text/cloud-config" + content = templatefile("${path.module}/assets/cloud-init/cloud-config.yaml", { + cluster_name = module.cluster_label.id + cloudwatch_agent_config_encoded = base64encode( + templatefile("${path.module}/assets/cloud-init/cloudwatch-agent-config.json", { + cluster_log_group_name = aws_cloudwatch_log_group.this[0].name + }) + ) + }) + } + + part { + content_type = "text/x-shellscript" + content = file("${path.module}/assets/cloud-init/start_core_services.sh") + } + + dynamic "part" { + for_each = var.instance_userdata_scripts + + content { + content_type = "text/x-shellscript" + content = part.value + } + } + + part { + content_type = "text/x-shellscript" + content = templatefile("${path.module}/assets/cloud-init/userdata.sh", { + ecs_cluster_name = module.cluster_label.id + aws_account_id = local.aws_account_id + aws_region_name = local.aws_region_name + aws_kv_namespace = local.aws_kv_namespace + }) + } +} + +resource "aws_launch_template" "this" { + count = local.self_hosted_enabled ? 1 : 0 + + name = module.cluster_label.id + image_id = data.aws_ssm_parameter.ecs_optimized_ami_id.value + user_data = data.template_cloudinit_config.this[0].rendered + update_default_version = true + + iam_instance_profile { + name = resource.aws_iam_instance_profile.this[0].id + } + + monitoring { + enabled = var.collected_metrics.instance.detailed_monitoring + } + + metadata_options { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "required" + instance_metadata_tags = "enabled" + } + + network_interfaces { + associate_public_ip_address = false + security_groups = distinct(concat([module.security_group.id], var.vpc_security_groups)) + } +} + +resource "aws_cloudwatch_log_group" "this" { + count = local.enabled ? 1 : 0 + + name = module.cluster_label.id + retention_in_days = var.log_retention + + tags = module.cluster_label.tags +} + +# ----------------------------------------------------------- security-group --- + +module "security_group" { + source = "cloudposse/security-group/aws" + version = "2.2.0" + + enabled = local.self_hosted_enabled + vpc_id = var.vpc_id + create_before_destroy = false + preserve_security_group_id = true + allow_all_egress = true + + rules = [ + { + key = "group-ingress" + type = "ingress" + from_port = 8192 + to_port = 65535 + protocol = "tcp" + description = "allow traffic within group" + cidr_blocks = [] + ipv6_cidr_blocks = [] + source_security_group_id = null + self = true + }, + ] + + tags = merge(module.cluster_label.tags, { Name = module.cluster_label.id }) + context = module.cluster_label.context +} + +# ---------------------------------------------------------------------- iam --- + +resource "aws_iam_instance_profile" "this" { + count = local.enabled ? 1 : 0 + + name = module.cluster_label.id + role = aws_iam_role.this[0].name +} + +resource "aws_iam_role" "this" { + count = local.enabled ? 1 : 0 + + name = module.cluster_label.id + description = "" + assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json + max_session_duration = "3600" + + managed_policy_arns = distinct(concat([ + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy", + ], var.iam_policy_arns)) + + inline_policy { + name = "instance-access" + policy = data.aws_iam_policy_document.cluster_instance_access.json + } + + tags = module.cluster_label.tags +} + +data "aws_iam_policy_document" "ec2_assume_role" { + statement { + sid = "AllowEc2Service" + effect = "Allow" + + principals { + type = "Service" + identifiers = [ + "ec2.amazonaws.com", + ] + } + + actions = [ + "sts:AssumeRole", + "sts:TagSession", + ] + } +} + +data "aws_iam_policy_document" "cluster_instance_access" { + statement { + sid = "AllowEcsClusterAccess" + effect = "Allow" + + actions = [ + "autoscaling:DescribeAutoScalingGroups", + "autoscaling:DescribeAutoScalingInstances", + "ec2:DescribeInstance*", + "ec2:DescribeTags", + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:Describe*", + "ecs:DiscoverPollEndpoint", + "ecs:List*", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:CreateLogGroup", + "logs:DescribeLogStreams", + "logs:TagResource", + ] + + resources = [ + "*" + ] + } +} + +# ======================================================= capacity-providers === + +module "capacity_provider_label" { + source = "cloudposse/label/null" + version = "0.25.0" + for_each = local.cluster_node_types + + delimiter = "_" + label_order = ["name", "attributes"] + label_key_case = "upper" + + context = module.cluster_node_label[each.key].context +} + +resource "aws_ecs_cluster_capacity_providers" "this" { + count = local.self_hosted_enabled ? 1 : 0 + + cluster_name = aws_ecs_cluster.this[0].name + capacity_providers = concat(["FARGATE", "FARGATE_SPOT"], [for x in aws_ecs_capacity_provider.this : x.name]) + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} + +resource "aws_ecs_capacity_provider" "this" { + for_each = local.cluster_node_types + + name = replace(upper(module.capacity_provider_label[each.key].id), "-", "_") + + auto_scaling_group_provider { + auto_scaling_group_arn = aws_autoscaling_group.this[each.key].arn + managed_termination_protection = "DISABLED" + + managed_scaling { + maximum_scaling_step_size = 100 + minimum_scaling_step_size = 1 + status = "ENABLED" + target_capacity = 100 + } + } +} + +# ================================================================== lookups === + +data "aws_ssm_parameter" "ecs_optimized_ami_id" { + name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id" +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..817083a --- /dev/null +++ b/outputs.tf @@ -0,0 +1,11 @@ +# ================================================================ resources === + +output "security_group_id" { + value = module.security_group.id + description = "Security group ID for the ECS cluster." +} + +output "security_group_name" { + value = module.security_group.name + description = "Security group name for the ECS cluster." +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..c371de6 --- /dev/null +++ b/variables.tf @@ -0,0 +1,115 @@ +# ============================================================== ecs-cluster === + +variable "self_hosted" { + type = bool + description = "Toggle self-hosted cluster." + default = false +} + +variable "autoscale_count_range" { + type = object({ + spot = optional(string, "0-3") + ondemand = optional(string, "0-3") + }) + description = "Autoscale range for spot and on-demand (format `\"0-3\"`)." + default = {} +} + +# ----------------------------------------------------------------- instance --- + +variable "instance_sizes" { + type = list(string) + description = "List of instance sizes (aka types) for the cluster." + default = ["m5d.large", "m5ad.large"] +} + +variable "instance_userdata_scripts" { + type = list(string) + description = "List of user data scripts for the instances." + default = [] +} + +# ------------------------------------------------------------ observability --- + +variable "log_retention" { + type = number + description = "Number of days to retain logs." + default = 90 +} + +variable "collected_metrics" { + type = object({ + cluster = optional(object({ + insights_enabled = optional(bool, true) + }), {}) + instance = optional(object({ + detailed_monitoring = optional(bool, true) + }), {}) + autoscaling_group = optional(object({ + granularity = optional(string, "1Minute") + metrics = optional(list(string), [ + "GroupMinSize", + "GroupMaxSize", + "GroupDesiredCapacity", + "GroupInServiceInstances", + "GroupPendingInstances", + "GroupStandbyInstances", + "GroupTerminatingInstances", + "GroupTotalInstances", + "GroupInServiceCapacity", + "GroupPendingCapacity", + "GroupStandbyCapacity", + "GroupTerminatingCapacity", + "GroupTotalCapacity", + ]) + }), {}) + }) + description = "Configuration of the cluster and instance metrics collection settings." + default = {} +} + +# ---------------------------------------------------------------------- iam --- + +variable "iam_policy_arns" { + description = "List of IAM policy ARNs to attach." + type = list(string) + default = [] +} + +# ------------------------------------------------------------------ network --- + +variable "vpc_id" { + description = "ID of the VPC for the resources." + type = string +} + +variable "vpc_subnet_ids" { + description = "IDs of the subnets in the VPC for the resources." + type = list(string) +} + +variable "vpc_security_groups" { + description = "List of security groups to attach to resources." + type = list(string) + default = [] +} + +# ================================================================== context === + +variable "aws_region_name" { + type = string + description = "The name of the AWS region." + default = "" +} + +variable "aws_account_id" { + type = string + description = "The ID of the AWS account." + default = "" +} + +variable "aws_kv_namespace" { + type = string + description = "The namespace or prefix for AWS SSM parameters and similar resources." + default = "" +} diff --git a/version.tf b/version.tf new file mode 100644 index 0000000..2238ace --- /dev/null +++ b/version.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0, < 6.0.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0.0" + } + template = { + source = "hashicorp/template" + version = ">= 2.2.0" + } + } +}