Translate

пʼятниця, 9 липня 2021 р.

Atlantis. Terraform Pull Request Automation


Цього разу піде мова про Atlantis, програму що автоматизує роботу із Terraform та в деяких випадках може значно спростити взаємодію із ним.

Atlantis - це самодостатній додаток на Golang, що слідкує за відкритими Pull Requests системи контролю версій за допомогою event-ів, що власне остання і надсилає. Під системою контролю версій мається на увазі Github, GitLab, Bitbucket чи Azure DevOps. Тобто це дещо складніше ніж просто Git, адже у неї має бути свій API, web-панель для рецензування та перегляду коду і т.п.

Із Atlantis зі змінами Terraform коду можна взаємодіяти напряму із web-панелі системи контролю версій, слідкувати за останніми змінами, перевіряти чи ці зміни були справді застосовані перед прийняттям коду в main вітку та застосовувати їх. Це справді може бути корисним та зручним, все залежить від процесів всередині команди та наскільки подібна логіка прийнятна.

У статті я розгляну 2 випадки установки та використання Atlantis. Перший, простіший, із застосуванням коду напряму через Terraform, а наступний - через, мабуть, найвідоміший wrapper Terragrunt. У якості системи контролю версій я буду використовувати Github, a сам Atlantis запустимо в Докері.


1. BASIC ATLANTIS USAGE


1.1. BASIC TERRAFORM CODE PREPARATION

Створимо тестовий репозиторій в GitHub для розміщення тестового Terraform коду. Не буду довго на цьому зупинятись, адже в даному процесі немає нічого особливого.

Опишемо створення звичайного EC2 інстансу за допомогою мови HCL останньої версії Terraform. На момент написання статті остання версія - це v1.0.1 

Створимо окрему директорію для коду:

$ mkdir simple-web
$ cd simple-web

Опишемо провайдера, s3-бакет для стейтів та dynamo-db таблицю для локів. Це все важливо як для звичайного використання Тераформу, так і для використання його в тандемі із Atlantis.

$ cat terraform.tf

provider "aws" {
  region              = var.region
  allowed_account_ids = [var.account_id]
  profile             = var.profile
}

# no interpolation here
terraform {
  backend "s3" {
    bucket         = "mine-tf-states"
    encrypt        = true
    dynamodb_table = "mine-tf-locks"
    key            = "mine-inf.tfstate"
    region         = "us-east-1"
    profile        = "my-aws-account"
  }
}

Security-групи для майбутнього EC2 інстансу. Лишимо відкритими лише ssh та http порти.

$ cat 01-sgs.tf

resource "aws_security_group" "web_sg" {
  name = "web-sg"
  lifecycle {
    create_before_destroy = true
  }
  tags = {
    Name = "web-sg"
  }
}

resource "aws_security_group_rule" "allow_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web_sg.id
  from_port   = var.http_port
  to_port     = var.http_port
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_ssh_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web_sg.id
  from_port   = var.ssh_port
  to_port     = var.ssh_port
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.web_sg.id
  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

Власне створення самого EC2-інстансу та ssh-ключа для доступу до нього:

$ cat 02-ec2.tf

resource "aws_key_pair" "my_key" {
  key_name   = "my-key"
  public_key = var.public_key
}

resource "aws_instance" "web" {
  # Ubuntu Server 20.04 LTS (HVM), SSD Volume Type in us-east-1
  ami                    = "ami-0dd76f917833aac4b"
  instance_type          = "t3a.nano"
  key_name               = aws_key_pair.my_key.key_name
  vpc_security_group_ids = ["${aws_security_group.web_sg.id}"]
  
  tags = {
    Name = "web"
  }

  user_data = <<-EOF
            #!/bin/bash
            echo "<h1>Say hello to Atlantis!</h1>" > index.html
            nohup busybox httpd -f -p 8080 &
            EOF
}

Виведемо адресу майбутньої новоствореної віртуальної машини в bash-сесію:

$ cat 99-outputs.tf

output "web_public_ip" {
  value = aws_instance.web.public_ip
}


Ну і всі необхідні змінні:

$ cat variables.tf

variable "region" {
  description = "The AWS region to create resources in"
  type        = string
  default     = "us-east-1"
}

variable "profile" {

  description = "profile to use aws creds"
  type        = string
  default     = "my-aws-account"
}

variable "account_id" {

  description = "AWS account ID"
  type        = string
  default     = "889248482628"
}

variable "http_port" {

  description = "for HTTP requests"
  type        = string
  default     = 8080
}

variable "ssh_port" {

  description = "for SSH requests"
  type        = string
  default     = 22
}

# replace ssh key on your own

variable "public_key" {
  description = "ssh key for EC2 machines"
  type        = string
  default     = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDB6n9K78fB+4HzwqgwLQkd94bawaMdxF6LHqm4eeBWcvVKdE4EJwTGNA6QgfklJevZZFx/BU7xKn0I1iU86Z8Jwmn0dSaohFpcT+ckDoFfugbkQ28nO80rhOZK02m3U/HN7DjJePgLHkpMt6B527DFKrfNvFPZ9h/0EofH/4V2Xj6YFeaomxXtg31hZ7qcKSrI54YmtzFhUau0H6ndr45jVgzTqxYiIk/ioARt/hsmAzglym0/OE2qsGgpf+0gXZo7sLjvuaMhLuM7RqryOqEddqLeZnksJ6hippNUoC9esgJSBWrUSCTRenjurPDtqK2ZN7BS55Z3LG0eOsonJqBB my-email@example.com"
}

Gitignore варто створити вже за власним смаком:

$ cat ../.gitignore

.terraform
.terraform.lock.hcl

Не варто сильно орієнтуватись на якість цього коду, він приведений лиш для прикладу.

Установимо awscli, додамо AWS-профіль із необхідними доступами та назвемо його my-aws-account. Створимо s3-бакет для стейту та увімкнемо його версіонування:

$ aws s3api --profile my-aws-account create-bucket \
      --bucket mine-tf-states \
      --region us-east-1

$ aws s3api --profile my-aws-account put-bucket-versioning \
      --bucket mine-tf-states \
      --versioning-configuration Status=Enabled


Також створимо DynamoDB-таблицю, що буде виконувати роль локів запуску. Це унеможливить одночасний запуск того ж самого терраформ коду, а для Atlantis це більш ніж актуально:

$ aws dynamodb --profile my-aws-account create-table \
      --region us-east-1 \
      --table-name mine-tf-locks\
      --attribute-definitions AttributeName=LockID,AttributeType=S \
      --key-schema AttributeName=LockID,KeyType=HASH \
      --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

Перед комітом коду, його варто перевірити локально. Це правило стосується не лише даного конкретного випадку, це хороше правило для всього життя:

$ terraform init
$ terraform apply

Plan: 6 to add, 0 to change, 0 to destroy.
Changes to Outputs:
  + web_public_ip = (known after apply)
...
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:
web_public_ip = "54.242.154.152"

Комітимо код у виществорений репозиторій. Має щось вийти на зразок цього:



1.2. BASIC ATLANTIS PREPARATION

Перед стартом Atlantis створимо нового користувача в GitHub. Це дуже бажано зробити, хоча і не обов'язково, адже таким чином людям, що працюють разом із вами, буде простіше зрозуміти, хто публікує коментарі до пулл ріквестів:

Створимо токен для вищезгаданого користувача. Для цього потрібно перейти по пунктам Settings -> Developer settings -> Generate new token, обрати scope repo та поки занотувати кудись токен:

Є також і інші варіанти надання доступу користувачу .

Atlantis-у також необхідний спеціальний секрет для вебхуків, для підвищення рівня безпеки. Згенерувати його просто і можна це зробити будь яким зручним для вас способом, наприклад за посиланням.

Додамо його до конфігурації репозиторію дещо пізніше, а наразі сфокусуємось на запуску Atlantis. Для його роботи потрібно надати всі необхідні доступи та ключі, тому створимо директорії, котрі надалі змонтуємо в контейнер:

$ mkdir -p ~/atlantis/{.ssh,.aws,config}
$ cp ~/.ssh/yourkey ~/atlantis/.ssh/
$ cp ~/.aws/credentials ~/atlantis/.aws/
$ chmod 600 ~/atlantis/.ssh/yourkey
$ chown 100.1000 -R ~/atlantis/

Публічну частину ключа необхідно додати до налаштувань користувача GitHub:

Підготувавши всі необхідні доступи, запустимо контейнер:

$ docker run -d -p 4141:4141 \
   -e GIT_SSH_COMMAND="ssh -i /home/atlantis/.ssh/yourkey -o 'StrictHostKeyChecking no'" \
   -v /home/ipeacocks/atlantis:/home/atlantis \
   ghcr.io/runatlantis/atlantis server \
   --atlantis-url="http://my.public.node.address:4141" \
   --gh-user=alantis-tf-bot \
   --gh-token=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 \
   --repo-allowlist=github.com/ipeacocks/atlantis-terraform-1 \
   --gh-webhook-secret=rojkznpcrddoilcvoxmxmcskvgbgkoflvafyaipqgjrchtuagugqzqbyquydapeutxyagqo

$ docker ps

CONTAINER ID   IMAGE                  COMMAND                  CREATED         STATUS         PORTS                                       NAMES
c239b75f51e0   runatlantis/atlantis   "docker-entrypoint.s…"   2 minutes ago   Up 2 minutes   0.0.0.0:4141->4141/tcp, :::4141->4141/tcp   amazing_noether

my.public.node.address:4141 - публічна адреса вузла, адже GitHub має відправляти вебхуки на нього. Ми скористались офіційним latest образом, проте загалом користуватись цим тегом - погана практика.

Додаємо вебхук до репозиторію GitHub. Для цього потрібно виконати наступне:

  • Натиснути Webhooks в налаштуваннях GitHub-репозиторію
  • Натиснути Add webhook
  • В поле Payload URL написати http://my.public.node.address:4141/events (чи https:// у випадку активного SSL), тобто публічна адреса, на якій працює Atlantis
  • У випадаючому списку Content type обрати application/json
  • Вписати попередньо згенерований секрет до поля Webhook Secret, що ми згенерували раніше (він може бути лише один)
  • Обрати Let me select individual events та встановити прапорці навпроти наступних полів: Pull request reviews, Pushes, Issue comments, Pull requests
  • Лишити активованим перемикач Active
  • Натиснути Add webhook

Все готово, перевіряємо роботу Atlantis. Для цього створимо пулл ріквест, в котрому змінили лише тип EC2-інстансу.

Одразу після створення пулл ріквесту, Atlantis запускає terraform plan:

Із Atlantis також можна взаємодіяти напряму в тілі пулл ріквесту і, наприклад, попросити застосувати код:

До закриття пулл ріквесту Atlantis продовжує зберігати план:

Описана вище логіка є стандартною, що активована по-замовчуванню. Звісно її можна змінити і про це ми поговоримо далі, якщо ви ще не стомились і тема залишилась цікавою.


2. ATLANTIS USAGE WITH TERRAGRUNT AND FEW TERRAFORM VERSIONS

Часто Terraform не використовують напряму, а використовують для цього врапери на зразок Terragrunt чи Terraspace. Перший допомагає ліпше організувати код та позбавитись повторень змінних. Також він вміє описувати залежності при запуску коду із декількох директорій та навчився управляти локами та писати стейти в s3 раніше за сам Terraform. 


2.1. BASIC TERRAFORM/TERRAGRUNT CODE PREPARATION

Ця стаття зовсім не про подібні врапери, але для того щоб продемонструвати складнішу структуру, більш схожу на справжній проект, необхідно буде також торкнутись і роботи Terragrunt. Виходячи з тих же обставин, зробимо припущення, що у нас також лишився код на Terrafrom 0.11 версії, останньої перед міграції на HCL2.

Отже встановимо Terraform v0.11.15 (для legacy коду) та v1.0.1. Також завантажимо останній Terragrunt v0.31.0.

# terragrunt
$ curl -fsSL https://github.com/gruntwork-io/terragrunt/releases/download/v0.31.0/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt
$ chmod +x /usr/local/bin/terragrunt

# terraform 0.11.15
$ curl -fsSL https://releases.hashicorp.com/terraform/0.11.15/terraform_0.11.15_linux_amd64.zip -o /tmp/terraform_0.11.15.zip
$ unzip /tmp/terraform_0.11.15.zip -d /usr/local/bin/
$ mv /usr/local/bin/terraform /usr/local/bin/terraform-011
$ chmod +x /usr/local/bin/terraform-011
$ rm -rf /tmp/terraform_011.zip

# terraform 1.0.1
$ curl -fsSL https://releases.hashicorp.com/terraform/1.0.1/terraform_1.0.1_linux_amd64.zip -o /tmp/terraform_1.0.1.zip
$ unzip -o /tmp/terraform_1.0.1.zip -d /usr/local/bin
$ chmod +x /usr/local/bin/terraform
$ rm -rf /tmp/terraform_1.0.1.zip

У якості прототипу візьмемо код із моєї попередньої статті та дещо змінимо його. Спочатку створимо директорії:

$ mkdir -p atlantis-terraform-2/env-dev/{global,us-east-1}

Отже global я зазвичай лишаю для речей, які не мають регіону, наприклад IAM-ресурси чи глобальний бакет для секретів. А в us-east-1 буде розміщений код специфічних для регіону ресурсів. Опишемо загальний конфігураційний файл Террагранту для всього регіону us-east-1:

$ cd atlantis-terraform-2/env-dev/us-east-1

$ cat terragrunt.hcl

remote_state {
  backend = "s3"
  config = {
    region         = "us-east-1"
    profile        = "env-dev"
    bucket         = "env-dev-tf-states-us-east-1"
    key            = "us-east-1/${path_relative_to_include()}/terraform.tfstate"
    encrypt        = true
    dynamodb_table = "env-dev-tf-locks-us-east-1"
  }
}

terraform {
  extra_arguments "common_var" {
    commands = [
      "apply",
      "plan",
      "import",
      "push",
      "destroy",
      "refresh",
      "apply-all",
      "destroy-all"
    ]
  }
}

inputs = {
  region           = "us-east-1"
  account_id       = "889248482628
"
  profile          = "env-dev"
  environment_name = "env-dev"
  remote_states_bucket        = "env-dev-tf-states-us-east-1"
  global_remote_states_bucket = "env-dev-tf-states-us-east-1"
}

Тут ми описали загальні змінні (секція inputs), таблицю DynamoDB для локів та s3 бакет для стейтів Тераформу (секція remote_state). Надалі локи/стейти описувати не потрібно.

Створимо щойно описані ресурси:

$ aws s3api --profile env-dev create-bucket \
      --bucket env-dev-tf-states-us-east-1 \
      --region us-east-1

$ aws s3api --profile env-dev put-bucket-versioning \
      --bucket env-dev-tf-states-us-east-1 \
      --versioning-configuration Status=Enabled

$ aws dynamodb --profile env-dev create-table \
      --region us-east-1 \
      --table-name env-dev-tf-locks-us-east-1 \
      --attribute-definitions AttributeName=LockID,AttributeType=S \
      --key-schema AttributeName=LockID,KeyType=HASH \
      --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

env-dev - це профіль із доступом до аккаунту AWS, тобто ~/.aws/credentials має бути відповідно заповнений.

Створимо директорію для коду мережі та опишемо її створення:

$ mkdir atlantis-terraform-2/env-dev/us-east-1/10-network
$ cd atlantis-terraform-2/env-dev/us-east-1/10-network

$ cat 00-init.tf

provider "aws" {
  region              = "${var.region}"
  allowed_account_ids = ["${var.account_id}"]
  profile             = "${var.profile}"
}

terraform {

  backend "s3" {}
}


$ cat 01-network.tf

# Declare the data source
# All available zones (AZ) list
data "aws_availability_zones" "available" {}

resource "aws_vpc" "this" {

  cidr_block           = "${var.vpc_cidr}"
  enable_dns_support   = "${var.enable_dns_support}"
  enable_dns_hostnames = "${var.enable_dns_hostnames}"
  
  tags {
    Name = "${var.environment_name}"
  }
}

resource "aws_subnet" "public" {

  vpc_id     = "${aws_vpc.this.id}"
  cidr_block = "${var.subnet_public_cidr}"
  
  tags {
    Name = "${var.environment_name}-pub"
  }
  availability_zone = "${data.aws_availability_zones.available.names[0]}"
}

resource "aws_subnet" "private" {

  vpc_id     = "${aws_vpc.this.id}"
  cidr_block = "${var.subnet_private_cidr}"
  
  tags {
    Name = "${var.environment_name}-priv"
  }
  availability_zone = "${data.aws_availability_zones.available.names[0]}"
}

resource "aws_internet_gateway" "this" {

  vpc_id = "${aws_vpc.this.id}"
  
  tags {
    Name = "${var.environment_name}"
  }
}

resource "aws_eip" "private_nat" {

  vpc = true

  tags {

    Name = "${var.environment_name}-priv"
  }
}

# EIP of NAT Gateway needs to be in public network

resource "aws_nat_gateway" "private" {
  allocation_id = "${aws_eip.private_nat.id}"
  subnet_id     = "${aws_subnet.public.id}"
  depends_on    = ["aws_internet_gateway.this"]
  
  tags {
    Name = "${var.environment_name}-priv"
  }
}


$ cat 02-routing.tf

resource "aws_route_table" "public" {
  vpc_id = "${aws_vpc.this.id}"

  tags {

    Name = "${var.environment_name}-pub"
  }
}

resource "aws_route" "to_internet_gateway" {

  route_table_id         = "${aws_route_table.public.id}"
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = "${aws_internet_gateway.this.id}"
}

resource "aws_route_table_association" "public" {

  subnet_id      = "${aws_subnet.public.id}"
  route_table_id = "${aws_route_table.public.id}"
}

resource "aws_route_table" "private" {

  vpc_id = "${aws_vpc.this.id}"

  tags {

    Name = "${var.environment_name}-priv"
  }
}

resource "aws_route" "to_nat_gateway" {

  route_table_id         = "${aws_route_table.private.id}"
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = "${aws_nat_gateway.private.id}"
}

resource "aws_route_table_association" "private" {

  subnet_id      = "${aws_subnet.private.id}"
  route_table_id = "${aws_route_table.private.id}"
}


$ cat 99-output.tf

output "vpc_id" {
  value = "${aws_vpc.this.id}"
}

output "public_subnet_id" {

  value = "${aws_subnet.public.id}"
}

output "private_subnet_id" {

  value = "${aws_subnet.private.id}"
}

Це вже власне локальний файл terragrunt.hcl, котрий вказує на використання головного terragrunt.hcl із глобальними змінними та параметрами. Також тут вказано, що потрібно використовувати legacy terraform v0.11:

$ cat terragrunt.hcl

# Terragrunt stuff
include {
  path = "${find_in_parent_folders()}"
}

dependencies {
  paths = []
}

terraform_binary             = "terraform-011"
terraform_version_constraint = ">= 0.11"

$ cat variables.tf

variable "region" {
  description = "The region of AWS"
}

variable "environment_name" {
  description = "Environment name to tag EC2 resources with (tag=environment)"
}

variable "profile" {
  description = "profile to use aws creds"
}

variable "account_id" {
  description = "profile to use aws creds"
}

variable "remote_states_bucket" {
  description = "Bucket name to store remote states and import/export outputs"
}

# Local vars
variable "vpc_cidr" {
  default     = "172.28.0.0/16"
  description = "vpc cidr"
}

variable "subnet_public_cidr" {
  default     = "172.28.0.0/24"
  description = "cidr of the public subnet"
}

variable "subnet_private_cidr" {
  default     = "172.28.3.0/24"
  description = "cidr of the private subnet"
}

variable "enable_dns_support" {
  description = "Enable DNS support in VPC"
  default     = true
}

variable "enable_dns_hostnames" {
  description = "Enable DNS hostnames in VPC"
  default     = true
}

Пояснення до описаного коду можна знайти у статті, на яку я вже посилався. Тут загалом VPC, публічна та приватна мережі та все, що необхідно для їх роботи. Код навмисно написаний на 0.11 терраформі для надання інфраструктури вигляду реального, часом legacy, вигляду. Опис мережі - це мабуть найкласичніший варіант.

Тепер опишемо створення DNS-зони та опцій DHCP для виществореного VPC, проте вже на 1.0.1 терраформі:

$ mkdir -p atlantis-terraform-2/env-dev/20-route53
$ cd atlantis-terraform-2/env-dev/20-route53

$ cat 00-init.tf

provider "aws" {
  region              = var.region
  allowed_account_ids = [var.account_id]
  profile             = var.profile
}

terraform {
  backend "s3" {}
}

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket  = var.remote_states_bucket
    key     = format("%s/10-network/terraform.tfstate", var.region)
    region  = var.region
    profile = var.profile
  }
}

$ cat 01-main.tf

resource "aws_vpc_dhcp_options" "this" {
  domain_name         = var.dns_zone_name
  domain_name_servers = ["AmazonProvidedDNS"]

  tags = {
    Name = var.environment_name
  }
}

resource "aws_vpc_dhcp_options_association" "this" {
  vpc_id          = data.terraform_remote_state.network.outputs.vpc_id
  dhcp_options_id = aws_vpc_dhcp_options.this.id
}

resource "aws_route53_zone" "internal" {
  name = var.dns_zone_name

  vpc {
    vpc_id = data.terraform_remote_state.network.outputs.vpc_id
  }
}

$ cat 99-output.tf

output "domain_internal_zone_name" {
  description = "Internal domain name"
  value       = aws_route53_zone.internal.name
}

output "domain_internal_zone_id" {
  description = "Internal domain zone id"
  value       = aws_route53_zone.internal.id
}

$ cat terragrunt.hcl

include {
  path = "${find_in_parent_folders()}"
}

dependencies {
  paths = []
}

$ cat variables.tf

variable "region" {
  description = "The region of AWS"
}

variable "environment_name" {
  description = "Environment name to tag EC2 resources with (tag=environment)"
}

variable "profile" {
  description = "profile to use aws creds"
}

variable "account_id" {
  description = "profile to use aws creds"
}

variable "remote_states_bucket" {
  description = "Bucket name to store remote states and import/export outputs"
}

variable "dns_zone_name" {
  description = "internal dns name"
  default     = "env-dev.int"
}

І нарешті перейдемо до створення кількох віртуальних машин за балансувальником, де і запустимо наш тривіальний сервіс. Спочатку опишемо створення ключа:

$ mkdir -p atlantis-terraform-2/env-dev/30-ec2/10-keypair
$ cd atlantis-terraform-2/env-dev/30-ec2/10-keypair


$ cat 00-init.tf

provider "aws" {
  region              = var.region
  allowed_account_ids = [var.account_id]
  profile             = var.profile
}

terraform {

  backend "s3" {}
}

$ cat 01-main.tf

resource "aws_key_pair" "this" {
  key_name   = format("%s-main", var.environment_name)
  public_key = var.public_key
}

$ cat 99-output.tf

output "key_name" {
  value = "${aws_key_pair.this.key_name}"
}

$ cat terragrunt.hcl

include {
  path = "${find_in_parent_folders()}"
}

dependencies {

  paths = []
}

$ cat variables.tf 

variable "region" {

  description = "The region of AWS"
}

variable "environment_name" {

  description = "Environment name to tag EC2 resources with (tag=environment)"
}

variable "profile" {

  description = "profile to use aws creds"
}

variable "account_id" {

  description = "profile to use aws creds"
}

variable "remote_states_bucket" {

  description = "Bucket name to store remote states and import/export outputs"
}

# replace it on your own

variable "public_key" {
  default     = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDB6n9K78fB+4HzwqgwLQkd94bawaMdxF6LHqm4eeBWcvVKdE4EJwTGNA6QgfklJevZZFx/BU7xKn0I1iU86Z8Jwmn0dSaohFpcT+ckDoFfugbkQ28nO80rhOZK02m3U/HN7DjJePgLHkpMt6B527DFKrfNvFPZ9h/0EofH/4V2Xj6YFeaomxXtg31hZ7qcKSrI54YmtzFhUau0H6ndr45jVgzTqxYiIk/ioARt/hsmAzglym0/OE2qsGgpf+0gXZo7sLjvuaMhLuM7RqryOqEddqLeZnksJ6hippNUoC9esgJSBWrUSCTRenjurPDtqK2ZN7BS55Z3LG0eOsonJqBB my-email@example.com"
  description = "ssh key to use for EC2 machines"
}

Звісно для логічності існування цієї інфраструктури також потрібен бастіон хост в публічній мережі, для можливості аутентифікації на віртуальних машинах в приватній підмережі. Але я пропущу його створення, щоб ще більше не роздувати цю, і без того довжелезну, статтю.

$ mkdir -p atlantis-terraform-2/env-dev/30-ec2/{20-bastion,30-web}
$ cd atlantis-terraform-2/env-dev/30-ec2/30-web

$ cat 00-init.tf

provider "aws" {
  region              = var.region
  allowed_account_ids = [var.account_id]
  profile             = var.profile
}

terraform {
  backend "s3" {}
}

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket  = var.remote_states_bucket
    key     = format("%s/10-network/terraform.tfstate", var.region)
    region  = var.region
    profile = var.profile
  }
}

data "terraform_remote_state" "route53" {
  backend = "s3"
  config = {
    bucket  = var.remote_states_bucket
    key     = format("%s/20-route53/terraform.tfstate", var.region)
    region  = var.region
    profile = var.profile
  }
}

data "terraform_remote_state" "key" {
  backend = "s3"
  config = {
    bucket  = var.remote_states_bucket
    key     = format("%s/30-ec2/10-keypair/terraform.tfstate", var.region)
    region  = var.region
    profile = var.profile
  }
}

$ cat 01-main.tf

data "aws_ami" "ubuntu_latest" {
  most_recent = true

  filter {
    name   = "name"
    values = [format("ubuntu/images/hvm-ssd/ubuntu-%s-amd64-server-*", var.ubuntu_version)]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}

resource "aws_instance" "this" {
  count                  = var.instances_count
  ami                    = data.aws_ami.ubuntu_latest.id
  instance_type          = var.instance_type
  subnet_id              = data.terraform_remote_state.network.outputs.private_subnet_id
  vpc_security_group_ids = [aws_security_group.web.id]
  key_name               = data.terraform_remote_state.key.outputs.key_name

  tags = {
    Name = format("%s-%s-%s", var.environment_name, var.service_name, count.index)
  }

  user_data = <<-EOF
            #!/bin/bash
            echo "<h1>Say hello to Atlantis from web-${count.index}</h1>" > index.html
            nohup busybox httpd -f -p "${var.web_port}" &
            EOF

  root_block_device {
    volume_type = "gp2"
    volume_size = var.block_volume_size
  }
}

resource "aws_elb" "classic" {
  name            = format("%s-%s-classic", var.environment_name, var.service_name)
  subnets         = [data.terraform_remote_state.network.outputs.public_subnet_id]
  security_groups = [aws_security_group.web_clb.id]
  
  listener {
    instance_port     = var.web_port
    instance_protocol = var.protocol
    lb_port           = var.web_ext_port
    lb_protocol       = var.protocol
  }

  health_check {
    healthy_threshold   = var.hc_healthy_threshold
    unhealthy_threshold = var.hc_unhealthy_threshold
    timeout             = var.hc_timeout
    target              = format("%s:%s", var.protocol, var.web_port)
    interval            = var.hc_interval
  }

  instances                   = aws_instance.this[*].id
  idle_timeout                = var.idle_timeout
  connection_draining         = var.connection_draining
  connection_draining_timeout = var.connection_draining_timeout
  
  tags = {
    Name = format("%s-%s-classic", var.environment_name, var.service_name)
  }
}

resource "aws_route53_record" "this" {
  count   = length(aws_instance.this)
  zone_id = data.terraform_remote_state.route53.outputs.domain_internal_zone_id
  name    = format("%s-%s-%s", var.environment_name, var.service_name, count.index)
  type    = "A"
  ttl     = 60
  records = [element(aws_instance.this.*.private_ip, count.index)]
}

$ cat 02-sgs.tf

resource "aws_security_group" "web" {
  name   = format("%s-%s", var.environment_name, var.service_name)
  vpc_id = data.terraform_remote_state.network.outputs.vpc_id
  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = format("%s-%s", var.environment_name, var.service_name)
  }
}

resource "aws_security_group_rule" "allow_web_ssh_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web.id
  from_port   = var.ssh_port
  to_port     = var.ssh_port
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web.id
  from_port                = var.web_port
  to_port                  = var.web_port
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.web_clb.id
}

resource "aws_security_group_rule" "allow_web_icmp_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web.id
  from_port   = -1
  to_port     = -1
  protocol    = "icmp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.web.id
  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group" "web_clb" {
  name   = format("%s-%s-clb", var.environment_name, var.service_name)
  vpc_id = data.terraform_remote_state.network.outputs.vpc_id
  
  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = format("%s-web-clb", var.environment_name)
  }
}

resource "aws_security_group_rule" "allow_web_clb_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web_clb.id
  from_port   = var.web_ext_port
  to_port     = var.web_ext_port
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web_clb_icmp_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web_clb.id
  from_port   = -1
  to_port     = -1
  protocol    = "icmp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web_clb_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.web_clb.id
  from_port   = 0
  to_port     = 0
  protocol    = -1
  cidr_blocks = ["0.0.0.0/0"]
}

$ cat 99-output.tf

output "web-clb" {
  value = aws_elb.classic.dns_name
}

$ cat terragrunt.hcl

include {
  path = "${find_in_parent_folders()}"
}

dependencies {
  paths = ["../10-keypair", "../20-route53", "../10-network"]
}

Тут ми вже виставили залежності між ресурсами, що були створені раніше. Тобто, якщо запустити код в директорії як find ./ -type d -name ".terraform" | xargs rm -rf && terragrunt apply-all -auto-approve, то Terragrunt буде проходити по залежностям і застосовувати кожну із директорій.

$ cat variables.tf

variable "region" {
  description = "The region of AWS"
}

variable "environment_name" {
  description = "Environment name to tag EC2 resources with (tag=environment)"
}

variable "profile" {
  description = "profile to use aws creds"
}

variable "account_id" {
  description = "profile to use aws creds"
}

variable "remote_states_bucket" {
  description = "Bucket name to store remote states and import/export outputs"
}

variable "service_name" {
  description = "Service name"
  default     = "web"
}

variable "ubuntu_version" {
  description = "Ubuntu distro version"
  default     = "focal-20.04"
}

variable "ssh_port" {
  description = "SSH port number"
  default     = 22
}

variable "web_port" {
  description = "Web port number"
  default     = 8080
}

variable "web_ext_port" {
  description = "Web external port number"
  default     = 80
}

variable "block_volume_size" {
  description = "Block volume size"
  default     = 10
}

variable "instances_count" {
  description = "Amount of EC2 instances"
  default     = 2
}

variable "instance_type" {
  description = "Instance type"
  default     = "t3a.nano"
}

variable "protocol" {
  description = "Protocol name"
  default     = "TCP"
}

variable "hc_healthy_threshold" {
  description = "LB health check healthy threshold"
  default     = 3
}

variable "hc_unhealthy_threshold" {
  description = "LB health check unhealthy threshold"
  default     = 3
}

variable "hc_timeout" {
  description = "LB health check timeout"
  default     = 4
}

variable "hc_interval" {
  description = "LB health check interval"
  default     = 30
}

variable "idle_timeout" {
  description = "Idle timeout"
  default     = 90
}

variable "connection_draining" {
  description = "Do you wish to enable connection draining ?"
  default     = true
}

variable "connection_draining_timeout" {
  description = "Connection draining timeout"
  default     = 90
}

Врешті-решт структура директорій матиме вигляд:

$ tree -a atlantis-terraform-2/env-dev                                                                                                    
atlantis-terraform-2/env-dev
├── global
│   └── .placeholder
└── us-east-1
    ├── 10-network
    │   ├── 00-init.tf
    │   ├── 01-network.tf
    │   ├── 02-routing.tf
    │   ├── 99-output.tf
    │   ├── terragrunt.hcl
    │   └── variables.tf
    ├── 20-route53
    │   ├── 00-init.tf
    │   ├── 01-main.tf
    │   ├── 99-output.tf
    │   ├── terragrunt.hcl
    │   └── variables.tf
    ├── 30-ec2
    │   ├── 10-keypair
    │   │   ├── 00-init.tf
    │   │   ├── 01-main.tf
    │   │   ├── 99-output.tf
    │   │   ├── terragrunt.hcl
    │   │   └── variables.tf
    │   ├── 20-bastion
    │   │   └── .placeholder
    │   └── 30-web
    │       ├── 00-init.tf
    │       ├── 01-main.tf
    │       ├── 02-sgs.tf
    │       ├── 99-output.tf
    │       ├── terragrunt.hcl
    │       └── variables.tf
    └── terragrunt.hcl

Нарешті! Тепер тестуємо код локально, для цього потрібно запустити terragrunt apply в кожній директорії із кодом, розміщуємо його в GitHub-репозиторії та переходимо до наступного пункту.



2.2. ATLANTIS DOCKER IMAGE BUILD AND CONFIGURATION

Офіційний docker імедж, котрий було використано в першій частині статті, наразі нам не підходить, адже ми маємо запускати код через Terragrunt та до того ж всього у нас тепер дві версії Терраформу. Проте ми можемо взяти офіційний імедж за основу:

$ cat Dockerfile

FROM ghcr.io/runatlantis/atlantis:v0.17.2

ARG TG_VERSION="v0.31.0"
ARG TG_REPO=https://github.com/gruntwork-io/terragrunt/releases/download

ARG TF_VERSION="1.0.1"
ARG TF_REPO=https://releases.hashicorp.com/terraform

ARG TF_OLD_VERSION="0.11.15"

RUN apk add --update \
    unzip \
  && rm -rf /var/cache/apk/*

# remove default terraform
RUN rm -f /usr/local/bin/terraform

# terragrunt
RUN curl -fsSL "${TG_REPO}"/"${TG_VERSION}"/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt && \
    chmod +x /usr/local/bin/terragrunt

# terraform 0.11
RUN curl -fsSL "${TF_REPO}"/"${TF_OLD_VERSION}"/terraform_"${TF_OLD_VERSION}"_linux_amd64.zip -o /tmp/terraform_"${TF_OLD_VERSION}".zip && \
    unzip /tmp/terraform_"${TF_OLD_VERSION}".zip -d /usr/local/bin/ && \
    mv /usr/local/bin/terraform /usr/local/bin/terraform-11 && \
    chmod +x /usr/local/bin/terraform-11 && \
    rm -rf /tmp/terraform_"${TF_OLD_VERSION}".zip

# terrafrom current
RUN curl -fsSL "${TF_REPO}"/"${TF_VERSION}"/terraform_"${TF_VERSION}"_linux_amd64.zip -o /tmp/terraform_"${TF_VERSION}".zip && \
    unzip -o /tmp/terraform_"${TF_VERSION}".zip -d /usr/local/bin && \
    chmod +x /usr/local/bin/terraform && \
    rm -rf /tmp/terraform_"${TF_VERSION}".zip

Зберемо новий докер імедж:

$ docker build -t atlantis-custom .

Як і раніше копіюємо всі необхідні дані, що необхідні для роботи Atlantis (якщо цього ще не робили раніше):

$ mkdir -p ~/atlantis/{.ssh,.aws,config}
$ cp ~/.ssh/yourkey ~/atlantis/.ssh/
$ cp ~/.aws/credentials ~/atlantis/.aws/
$ chmod 600 ~/atlantis/.ssh/yourkey
$ chown 100.1000 -R ~/atlantis/

100.1000 - це uid.gid користувача atlantis всередині майбутнього контейнера. Створюємо конфігураційний файл, де вкажемо деталі як необхідно запускати Terraform код:

$ cat ~/atlantis/config/repos.yaml

repos:
- id: "/.*/"
  workflow: terragrunt
  # apply_requirements: [approved, mergeable]
  apply_requirements: [mergeable]
  allowed_overrides: [workflow]
  allow_custom_workflows: true
workflows:
  terragrunt:
    plan:
      steps:
      # simple hack for avoiding wrong colour interpretation during auto init by 'terragrunt plan'
      - run: terragrunt init -no-color && terragrunt plan -no-color -out=${PLANFILE}
    apply:
      steps:
      - run: terragrunt apply -no-color ${PLANFILE}

Тож ми вказали яких директорій все стосується, при яких умовах можна виконувати застосування коду ([mergeable], тобто якщо немає конфліктів. Проте у випадку роботи в команді ліпше обрати [approved, mergeable], тобто щоб ще хтось виставив approve вашим змінам). В розділі workflows вказуємо які повні команди стоять за plan та apply.

Як і в п.1.2 треба створити всі необхідні токени на стороні GitHub та вебхук до нового репозиторію github.com/ipeacocks/atlantis-terraform-2 в GitHub.

Запускаємо Atlantis:

$ docker run -d --name atlantis \
  -p 4141:4141 \
  -e GIT_SSH_COMMAND="ssh -i /home/atlantis/.ssh/yourkey -o 'StrictHostKeyChecking no'" \
  -v ~/atlantis:/home/atlantis \
  atlantis-custom server \
  --atlantis-url="http://my.public.node.address:4141" \
  --gh-user=alantis-tf-bot \
  --gh-token=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 \
  --repo-allowlist=github.com/ipeacocks/atlantis-terraform-2 \
  --gh-webhook-secret=rojkznpcrddoilcvoxmxmcskvgbgkoflvafyaipqgjrchtuagugqzqbyquydapeutxyagqo \
  --repo-config=/home/atlantis/config/repos.yaml

І тепер пересвідчимось що все дійсно працює. Для цього створимо нову бранчу, дещо змінимо та зробимо PR.

Після створення пулл ріквесту вебхук буде одразу відправлено на адресу Atlantis:


Проте таке стандартне поводження можна за необхідності змінити. Завдяки Террагранту, код в першій директорії буде застосований 0.11.15 Терраформом, а в другій - останньою версією 1.0.1. Прямо в тілі пулл ріквесту можна і застосувати код:

Після мерджу коду в main-вітку Atlantis видалить всі попередньо сформовані плани.


3. BOTTOM LINE

Надіюсь я вас не дуже стомив. На мою думку, необхідність в подібному сервісі для будь-яких команд не завжди є. Схоже Atlantis гарно підійде для команд, котрі володіють не надто великою та рідко змінюваною інфраструктурою. Складно уявити яку справжню користь принесе Atlantis у випадку гіганського пулл ріквесту із купою нових сервісів чи змін. Звісно такі пулл ріквести буде простіше переглядати, адже буде гарно відображено в яких директоріях зміни і чи застосований код у кожній із директорій. Але чи безпечно буде застосовувати так багато змін? Знову ж, чи мають зміни в пулл ріквесті бути застосовані перед його створенням? Якщо ні, то де вони попередньо мають бути протестовані? На окремому AWS аккаунті, котрий має бути піднятим лише для подібних сценаріїв? Якось забагато питань, що вже натякають на неуніверсальність інструменту.

Atlantis також може бути корисним у разі надання прав створювати ресурси окремим людям, в котрих немає доступ до адміністративної консолі AWS. Наприклад, якщо необхідно створити нового користувача чи інший простий ресурс без значних залежностей. Тобто пулл ріквест таким чином має бути переглянутий і у разі успіху може бути застосований Атлантісом і змерджений в основну вітку репозиторія.

У Atlantis є певні проблеми якщо необхідно працювати із ресурсами для яких необхідно знаходитись в одній підмережі при умові, що кодова база лежить в єдиному репозиторії, тобто коли dev/rc/prod описані в одному місці. Скажімо ви встановили RDS/mysql базу даних в dev середовищі (окрема мережа) і хочете додати користувача до неї. Atlantis знаходиться в prod-середовищі, і dev мережа банально недоступна. На думку одразу спадає підняти по Atlantis-серверу в кожній із мереж, а потім відправляти вебхуки із GitHub одночасно всім серверам...це можливо, але Atlantis не зрозуміє таких жестів, адже він просто запуститься двічі, бо не може розрізняти dev/rc/prod директорії і вибірково реагувати на зміни.


Немає коментарів:

Дописати коментар