Terraform 1.6 и встроенные тесты: подключаем terraform test к CI-конвейеру
HashiCorp выпустила Terraform 1.6 с нативным фреймворком тестирования. Разбираемся, как прикрутить terraform test к CI так, чтобы каждый PR проверялся на реальных API.
HashiCorp Terraform 1.6 выпущен со встроенным фреймворком тестирования terraform test, октябрь 2023
HashiCorp объявила о выходе Terraform 1.6 с нативным фреймворком тестирования - terraform test. До этого момента любая проверка инфраструктурного кода сводилась к одному из трёх: терраформ-шаблоны вручную прогоняются в стейджинге, используется Terratest на Go, или вообще ничего. Все три варианта имеют один общий недостаток - они не живут в самом Terraform и требуют отдельного инструментария. Теперь тесты встроены в CLI, и мы решили попробовать это на одном из клиентских проектов под управляемой инфраструктурой.
Контекст: что за проект и почему тесты вообще нужны
Речь идёт о кластере в облаке, где инфраструктурный код накапливался примерно полтора года. Модули для сетей, групп безопасности, нод баз данных, балансировщиков. Когда кодовая база небольшая и один человек держит всё в голове - можно обходиться без тестов. Когда модулей больше двадцати, а над ними работают трое и у каждого своё понимание «как оно должно работать» - начинаются сюрпризы в PR.
Мы хотели конкретного: чтобы каждый PR к инфраструктурному репозиторию прогонял тесты в изолированном workspace и показывал результат до мерджа. Не линтер, не terraform validate - а проверку того, что модуль реально создаёт нужные ресурсы с правильными параметрами.
Как устроен terraform test
terraform test читает файлы *.tftest.hcl из каталога tests/ (или рядом с модулем). Структура файла простая: блок run запускает terraform apply или terraform plan, а блоки assert проверяют атрибуты ресурсов через выражения.
Минимальный тест для модуля группы безопасности выглядит примерно так:
run "security_group_has_correct_tags" {
command = apply
assert {
condition = aws_security_group.main.tags["Environment"] == var.environment
error_message = "Security group environment tag mismatch"
}
assert {
condition = aws_security_group.main.ingress[0].from_port == 443
error_message = "Expected port 443 in ingress"
}
}
Важный момент: command = apply значит, что Terraform реально создаст ресурс в провайдере, проверит атрибуты и затем удалит его. Это не моки и не plan-only проверка - это работа с реальным API. После завершения тестового прогона всё автоматически уничтожается через terraform destroy. Если тест упал посередине - Terraform пытается убрать за собой, но это не гарантировано, об этом ниже.
Как встроили в CI
В качестве CI использовали GitLab. Базовая схема:
- При открытии PR создаётся временный workspace (
TF_WORKSPACE=pr-${CI_MERGE_REQUEST_IID}). - В этом workspace прогоняется
terraform testс нужными переменными окружения. - После завершения workspace удаляется.
Пайплайн-джоба в упрощённом виде:
terraform-test:
stage: test
image: hashicorp/terraform:1.6
script:
- terraform init
- terraform workspace new pr-${CI_MERGE_REQUEST_IID} || terraform workspace select pr-${CI_MERGE_REQUEST_IID}
- terraform test -var-file=envs/staging.tfvars
- terraform workspace select default
- terraform workspace delete pr-${CI_MERGE_REQUEST_IID}
only:
- merge_requests
На практике пришлось добавить обработку ошибок: если terraform test упал, workspace нужно всё равно почистить. Без этого через неделю-другую накапливается кладбище брошенных workspace и изолированных ресурсов, которые стоят денег.
Что пошло не так
Первое - время прогона. Тесты, которые делают apply, работают столько, сколько создаются реальные ресурсы. Для группы безопасности - секунды. Для RDS-инстанса - несколько минут. Для некоторых типов ресурсов в нашем облачном провайдере ждать пришлось дольше, чем хотелось бы. Мы разделили тесты на быстрые (plan-only, для проверки конфигурации) и полные (apply, только на определённые модули) и запускаем их раздельно.
Второе - изоляция провайдерских ресурсов. Некоторые ресурсы не удаляются чисто при terraform destroy - остаются объекты в облаке, о которых Terraform не знает. Мы добавили периодическую задачу, которая проверяет наличие ресурсов с тегом terraform-test=true старше двух часов и удаляет их. Костыль, но работает.
Третье - переменные. terraform test принимает переменные через -var и -var-file, но credentials провайдера нужно подавать отдельно через переменные окружения. Это логично, но первые два прогона упали именно из-за путаницы с тем, где какие значения брать.
Что получилось
После двух недель настройки схема работает. Каждый PR к инфраструктурному репозиторию получает статус от CI: тесты прошли или нет. Конкретно для нашего набора модулей прогон занимает от трёх до восьми минут в зависимости от того, что изменилось.
Один реальный эффект уже был: PR с изменением в сетевом модуле не прошёл тест на правила безопасности - автор добавил более широкое правило входящего трафика, которое конфликтовало с существующим ограничением. В ревью это могло бы проскочить - тест поймал.
Что ещё стоит знать
terraform test в версии 1.6 - это первый релиз фреймворка. Некоторых вещей пока нет: нет встроенного механизма мокирования провайдеров (всё работает с реальным API или через command = plan), нет параллельного запуска блоков run. Документация местами лаконичнее, чем хотелось бы.
Но для базового сценария - проверить, что модуль создаёт ресурсы с нужными атрибутами, и встроить это в PR-пайплайн - инструмент работает без лишних зависимостей. Это лучше, чем настраивать Terratest и тянуть Go-окружение в CI ради нескольких тестов.