Из ClickOps в код: как я оживил Terraformer и научил его говорить с провайдерами напрямую
- суббота, 26 сентября 2026 г. в 00:00:17
Почти у каждой команды есть «накликанная» инфраструктура. Кто‑то когда‑то создал VPC в консоли, кто‑то вручную настроил бакет, DNS‑зоны живут отдельно, а в Terraform описана только половина. Чтобы взять это под управление, нужно две вещи: найти всё, что существует, и написать для каждого объекта конфиг, который совпадает с реальностью.
Для этого был Terraformer от Waze SRE: 14,5 тысячи звёзд на GitHub, около 800 тысяч скачиваний релизов, 44 провайдера, от AWS и Google Cloud до Datadog, Cloudflare и Yandex Cloud. Одна команда, и у вас папка с .tf‑файлами. 16 марта 2026 года репозиторий перевели в архив.
Полноценной замены не появилось. Есть форк chenrui333/terraformer, который продолжает выпускать релизы, но он сохранил прежний подход: HCL плюс tfstate. Встроенные import‑блоки генерируют конфиг, но ID каждого ресурса нужно найти самому. В Terraform 1.14 появилась команда query для поиска ресурсов, но она есть только в Terraform и работает только с провайдерами, которые это поддерживают. В OpenTofu поиска нет вовсе: запрос висит со статусом «решение не принято».
Я взялся продолжить проект. Получился Unclick (GitHub, Apache-2.0). В статье расскажу, почему Terraformer перестал работать с современными провайдерами, как устроен протокол плагинов Terraform изнутри и как сделать так, чтобы сгенерированный конфиг проходил plan без ручной правки.

Первое, что видит человек, запустивший Terraformer в 2026 году с Cloudflare или любым провайдером на новом фреймворке:
Incompatible API version with plugin. Plugin version: 6, Client versions: [5]
Причина в том, как Terraformer был устроен. Внутри него лежала библиотека Terraform 0.12.31, то есть кусок самого Terraform образца 2021 года. Через неё он запускал плагины провайдеров. А Terraform 0.12 знает только пятую версию протокола плагинов.
Из той же зависимости выросли и остальные проблемы:
OpenTofu не поддерживался. Terraformer искал плагины в папках registry.terraform.io, а OpenTofu кладёт их в registry.opentofu.org.
Он писал terraform.tfstate третьей версии. Современные OpenTofu и Terraform такой state обновляют, но адрес провайдера provider.datadog превращается в hashicorp/datadog, а такого провайдера не существует. Отсюда в документации Terraformer инструкции с terraform state replace-provider.
В required_providers не было адреса провайдера. Terraformer знал правильный source только у 4 провайдеров из 44. Для остальных подразумевался hashicorp/<имя>, и init падал.
Плюс мелочи, которые находятся при первом же настоящем прогоне. Например, импортёр GitHub всегда получал из командной строки пустой --token и поэтому никогда не читал GITHUB_TOKEN, хотя документация обещала обратное.
Чинить это точечно бессмысленно, нужно было убрать Terraform 0.12 из зависимостей. Для этого пришлось разобраться, как Terraform на самом деле разговаривает с провайдерами.
Провайдер — это отдельная программа, terraform-provider-aws или terraform-provider-github. Terraform и OpenTofu запускают её как дочерний процесс через библиотеку HashiCorp go‑plugin и общаются с ней по gRPC.
Чтобы провайдер согласился работать, клиент должен пройти рукопожатие. Процесс проверяет «магическую куку» в переменной окружения; если её нет, провайдер пишет «This binary is a plugin» и завершается. Затем клиент сообщает, какие версии протокола он знает, а провайдер выбирает старшую общую:
var handshake = goplugin.HandshakeConfig{ ProtocolVersion: 4, MagicCookieKey: "TF_PLUGIN_MAGIC_COOKIE", MagicCookieValue: "d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2", } var versionedPlugins = map[int]goplugin.PluginSet{ 5: {"provider": &grpcPlugin{version: 5}}, 6: {"provider": &grpcPlugin{version: 6}}, }
Сам протокол описан в двух .proto‑файлах: tfplugin5.proto и tfplugin6.proto. Сейчас это версии 5.11 и 6.11. Готовый сгенерированный Go‑код лежит в terraform‑plugin‑go под лицензией MPL-2.0, я взял его как есть.
Для импорта нужна небольшая часть протокола:
Вызов | Зачем |
|---|---|
| Схема: какие есть ресурсы, какие у них атрибуты и блоки |
| Передать регион, токен и прочие настройки |
| Прочитать объект из облака по ID и известным атрибутам |
| Превратить ID в начальное состояние ресурса |
| Проверить конфиг, как это делает |
| Перечислить существующие объекты (новое, о нём ниже) |
Значения ходят в поле DynamicValue как msgpack, сериализованный по типу из схемы. Для этого есть библиотека cty, на которой построены и Terraform, и OpenTofu. Поэтому кодирование занимает одну строку:
func encode(v cty.Value, ty cty.Type) ([]byte, error) { return msgpack.Marshal(v, ty) }
Схемы у крупных провайдеров огромные. Например, AWS‑провайдер 6.66 описывает 1725 типов ресурсов и 683 источника данных. Поэтому лимит на размер gRPC‑сообщения нужно поднимать сразу, я поставил 256 МБ.
Сообщения протоколов 5 и 6 почти одинаковые. Была мысль писать код только против шестой версии, а ответы пятой перекодировать: сериализовать в protobuf и прочитать как сообщение v6. Я сравнил номера полей в обоих .proto и нашёл ловушку в описании атрибута:
Поле | Протокол 5 | Протокол 6 |
|---|---|---|
10 |
|
|
11 |
|
|
12 | — |
|
Перекодировка тихо превратила бы флаг «только для записи» во вложенный тип. Поэтому адаптеров два, для каждой версии свой. Дублирования вышло строк на двести, зато никакой магии.
Живая проверка:
Провайдер | Версия | Протокол |
|---|---|---|
hashicorp/random | 3.9.1 | 5 |
integrations/github | 6.13.0 | 5 |
hashicorp/aws | 6.66.0 | 5 |
cloudflare/cloudflare | 5.25.0 | 6 |
Cloudflare — как раз тот случай, на котором Terraformer падал.
Скачивать провайдеры самому я не стал. Реестр, зеркала, контрольные суммы, корпоративные прокси — всё это пользователь уже настроил для tofu init. Поэтому Unclick сначала ищет провайдер там, куда его кладут OpenTofu и Terraform:
.terraform/providers/<реестр>/<namespace>/<тип>/<версия>/<os_arch>/terraform-provider-<тип>_v<версия>
Он проверяет оба реестра, кэш плагинов из TF_PLUGIN_CACHE_DIR и пользовательские папки. Если провайдера нет, Unclick создаёт во временной папке файл с required_providers и запускает там tofu init (или terraform init, если OpenTofu не установлен). Так получаются ровно те же проверки и зеркала, что у самого пользователя.
Для каждого из 44 провайдеров теперь записан правильный адрес в реестре. Все адреса я сверил с репозиторием реестра OpenTofu, и одного там не оказалось: yandex-cloud/yandex. Провайдер Yandex Cloud публикуется только в реестре Terraform и на собственном зеркале Yandex, поэтому для него адрес записан с явным хостом registry.terraform.io/yandex-cloud/yandex.
Самая ценная часть Terraformer — это импортёры: код, который ходит в API каждого облака и собирает ID ресурсов. Это 79 тысяч строк на Go. Переписывать их не было ни смысла, ни желания.
Импортёры общаются с ядром через структуру из Terraform 0.12: ресурс как набор строк вида tags.Name = "web". Этот формат называется flatmap. Я оставил его как тонкий слой совместимости. Конвертацию flatmap ↔ cty перенёс из Terraform 0.12, сохранив для этих файлов лицензию MPL. А несколько структур состояния заменил простыми типами без логики. В итоге из 44 провайдеров правки понадобились в пяти файлах: там использовались мелкие хелперы Terraform вроде hashcode.String.
Ещё одна мелочь, которая сильно упростила жизнь: теги сборки. Каждый провайдер регистрирует себя сам, а файл с его командой начинается так:
//go:build !slim || aws
Обычная сборка включает все провайдеры, а go build -tags slim,aws собирает бинарник только с AWS. Это оказалось не только удобством, но и необходимостью, о чём ниже.
Критерий у меня был один: после tofu plan в сгенерированной папке должно быть написано
Plan: N to import, 0 to add, 0 to change, 0 to destroy.
Если меняется хоть что‑то, значит, конфиг не совпадает с реальностью, и человеку придётся разбираться руками. Состояние я больше не пишу: вместо terraform.tfstate генерируется imports.tf с блоком import на каждый ресурс. Пока пользователь не сделает apply, его state не трогается.
Дальше начались интересные вещи.
Первый прогон на моём аккаунте GitHub:
Error: Conflicting configuration arguments "private": conflicts with visibility
Атрибут private у github_repository давно устарел и заменён на visibility. Провайдер возвращает оба, генератор писал оба. Решение нашлось в схеме: у каждого атрибута есть флаг Deprecated. Теперь такие атрибуты не пишутся, как и вычисляемые (Computed без Optional).
С AWS ошибки оказались хитрее:
"ipv6_netmask_length": all of `ipv6_ipam_pool_id,ipv6_netmask_length` must be specified
Провайдеры на старом SDK (SDKv2) хранят в состоянии нулевые значения для полей, которые никто не задавал: 0, false, "". Если записать ipv6_netmask_length = 0 в конфиг, срабатывает правило „задавай только вместе с ipv6_ipam_pool_id“. Похожая история с map_customer_owned_ip_on_launch = false у подсетей.“»
Правила вида RequiredWith, ConflictsWith, ExactlyOneOf в протокол не передаются, по схеме их не узнать. Писать исключения под каждый ресурс — путь Terraformer, и он не масштабируется: у одного AWS 1725 типов ресурсов.
Но провайдер умеет проверять конфиг сам: для этого есть вызов ValidateResourceConfig, тот самый, на котором работает tofu validate. Плагин и так запущен, значит, можно отдать ему каждый сгенерированный ресурс и спросить, что не так:
for round := 0; round < maxFixRounds; round++ { config, _ := ItemValue(r.Item, block) // ссылки ${...} становятся unknown diags, _ := v.ValidateResourceConfig(r.InstanceInfo.Type, config) removed := false for _, d := range diags { if d.Error && removeOptional(r.Item, block, d.Path) { removed = true break // по одному аргументу за раунд } } if !removed { return } }
Диагностика приходит с путём к атрибуту. Если атрибут необязательный, он убирается, и ресурс проверяется заново. Обязательные аргументы не трогаются никогда. SDKv2 читает отсутствующее поле как тот же ноль, поэтому план не меняется.
Почему по одному аргументу за раунд? На паре availability_zone и availability_zone_id провайдер жалуется на оба сразу: каждый конфликтует с другим. Если убрать оба, конфликт исчезнет, но конфиг обеднеет. Если убрать один и перепроверить, второй остаётся.
Этот приём закрыл целый класс ошибок, и ни одной строчки под конкретный ресурс писать не пришлось.
Kubernetes на новом ядре сломался уже в CI:
Error: Extraneous label for selector on deployment.tf line 17: 17: selector "match_labels" {
Terraformer печатал конфиг через HCL первой версии и угадывал по данным, где блок, а где атрибут. Карта match_labels внутри блока selector превращалась в «блок с меткой». Такой HCL не разбирается ни OpenTofu, ни Terraform.
Я написал новый генератор HCL, который смотрит в схему провайдера. Там прямо сказано, что selector — вложенный блок, а match_labels — атрибут типа map(string). Заодно он:
пишет ссылки голыми выражениями: vpc_id = aws_vpc.main.id, а не "${aws_vpc.main.id}";
не заключает в кавычки числа и булевы значения;
многострочные документы вроде политик IAM пишет heredoc‑ом;
проверяет результат парсером HCL до записи на диск.
Одна тонкость. У ресурсов на SDKv2 бывают «атрибуты как блоки», например ingress и egress у aws_security_group. По схеме это атрибут типа «набор объектов», и в синтаксисе атрибута каждый объект обязан содержать все поля. Terraform для таких атрибутов принимает и блочный синтаксис, им пользуется вся документация AWS. Поэтому для SDKv2 они пишутся блоками, а для провайдеров на новом фреймворке — атрибутом с явными null у недостающих полей.
Импортёры Terraformer работают, но у них одна фундаментальная проблема: каждый новый тип ресурса — это новый код. AWS выпускает сервисы быстрее, чем их успевали добавлять.
В свежих версиях протокола появился вызов ListResource. Провайдер сам перечисляет существующие объекты своего типа и возвращает их поток вместе с полным состоянием. На нём построена команда query в Terraform 1.14. Поддержка у провайдеров растёт: по документации в их репозиториях на сентябрь 2026 года это 241 тип у AWS, 152 у Google и 105 у AzureRM.
Unclick вызывает ListResource напрямую, поэтому работает и с провайдерами, установленными OpenTofu:
unclick scan aws --config region=eu-west-1 unclick scan aws --config region=eu-west-1 --types aws_vpc,aws_subnet,aws_security_group
Дальше объекты проходят тот же конвейер: схема, удаление вычисляемых и устаревших полей, проверка провайдером, генератор HCL. Одного не хватает: список не знает о связях между объектами. Поэтому есть ещё один шаг. Если значение атрибута _id или _ids в точности совпадает с ID другого найденного ресурса, оно становится ссылкой:
resource "aws_subnet" "tfer--subnet-cba46c349dd682e2e" { cidr_block = "10.42.1.0/24" availability_zone = "us-east-1a" vpc_id = aws_vpc.tfer--vpc-2ab293a86f9299bce.id }
Главное в scan то, что в нём нет кода под конкретный ресурс. Новые типы появляются вместе с новыми версиями провайдера.
Гонять тесты на настоящем AWS за свои деньги я не хотел. Схема получилась такая.
AWS — на moto. Это эмулятор AWS API на Python. Тестовый скрипт создаёт в нём VPC, подсеть и группу безопасности с правилом, затем запускает unclick import и unclick scan, а потом tofu plan. И SDK импортёров, и сам провайдер понимают переменную AWS_ENDPOINT_URL, так что достаточно направить их на эмулятор.
С эмулятором был забавный момент: импорт S3 зависал на несколько минут. Оказалось, AWS‑провайдер 6.x читает теги бакета через S3 Control по адресу вида http://123456789012.127.0.0.1:5000/..., то есть приклеивает ID аккаунта к хосту. Для настоящего AWS это нормальное имя, а для локального эмулятора такой хост не резолвится, и SDK уходит в 25 повторов с нарастающими паузами. S3 в тесте на moto я исключил, в настоящем облаке этой проблемы нет.
Kubernetes — на kind в GitHub Actions. Тест создаёт пространство имён, ConfigMap и Deployment, импортирует их и проверяет план. Здесь всплыла особенность провайдера: у kubernetes_deployment есть настройка wait_for_rollout. Она живёт только на стороне клиента, поэтому при импорте её неоткуда взять, и первый план хочет записать её значение по умолчанию. В кластере от этого ничего не меняется. Тест разбирает план через tofu show -json, разрешает только это изменение и падает на любом другом.
GitHub — на настоящем аккаунте, только чтение. Импорт идёт через токен, план тоже только читает.
Результаты:
Сценарий | Итог |
|---|---|
| 48 to import, 0 to change |
| 12 to import, 0 to change |
| 7 to import, 0 to change |
| 3 to import, одно изменение |
Всё это крутится в CI вместе с полной сборкой и юнит‑тестами на Ubuntu и macOS.
Раз уж статья про ClickOps, расскажу и про собственный. Бинарник со всеми 44 провайдерами включает SDK всех облаков: AWS, Azure, Google, IBM, Tencent, Alibaba и других. Компилируется это больше десяти тысяч пакетов.
Первая сборка у меня упала с out of memory, причём не компиляция, а компоновка. Компоновщик Go на таком объёме хочет несколько гигабайт, а у меня параллельно были открыты браузер, Unity и несколько окон редактора, и файлу подкачки на почти заполненном системном диске некуда было расти. Параллельную сборку я ограничил флагом -p 3, но полный бинарник локально так и не скомпоновался.
Здесь и пригодились теги slim: для разработки я собираю только нужный провайдер, а полный бинарник под пять платформ собирает GoReleaser в GitHub Actions. Архив с ним весит 70–80 МБ.
Честно о том, что пока не так:
Сквозными тестами проверены AWS, GitHub и Kubernetes. Остальные импортёры компилируются и работают на новом ядре, но заново не перепроверялись. Особенно нужны живые проверки Tencent Cloud, Alibaba Cloud и Yandex Cloud: у меня нет в них аккаунтов с данными.
У некоторых ресурсов ID в состоянии не совпадает с ID для импорта. Для этого у ресурса есть поле ImportID, но таблицу таких случаев ещё предстоит собрать.
scan пока работает только с AWS, Google и AzureRM: остальные провайдеры list‑ресурсов ещё не поддерживают.
Попробовать:
go install github.com/Perruer/unclick@latest unclick scan aws --config region=eu-west-1 cd generated/aws && tofu init && tofu plan
Готовые бинарники для Linux, macOS и Windows лежат в релизах. Код: https://github.com/Perruer/unclick
Если вы пользовались Terraformer, напишите, какие провайдеры вам нужнее всего: буду проверять и чинить их в первую очередь.
Проект открытый и бесплатный.