Почти у каждой команды есть «накликанная» инфраструктура. Кто‑то когда‑то создал 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 разговаривает с провайдером
Провайдер — это отдельная программа, 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.
79 тысяч строк импортёров трогать не хотелось
Самая ценная часть 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. Это оказалось не только удобством, но и необходимостью, о чём ниже.
Конфиг, который проходит plan
Критерий у меня был один: после 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 у недостающих полей.
scan: пусть провайдер сам скажет, что есть
Импортёры 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, напишите, какие провайдеры вам нужнее всего: буду проверять и чинить их в первую очередь.
Проект открытый и бесплатный.
KrimsN
Интересно, что вы отказались от перекодировки v5 -> v6 из-за конфликта номеров полей (
write_only/nested_type) и пошли на дублирование адаптеров. А не рассматривали генерацию адаптеров из самих .proto-файлов через кодогенерацию, раз структура сообщений всё равно почти идентична? Или овчинка выделки не стоит на объёме в ~200 строк?Механизм с
ValidateResourceConfigи последовательным удалением конфликтующих optional-полей по одному за раунд — красивое решение для проблемы, которая в принципе не решается статическим анализом схемы. Держите в уме, чтоmaxFixRoundsможет не хватить на ресурсах с длинными цепочками зависимостей между атрибутами (Aконфликтует сB-> после удаленияA,Cконфликтует сB)? Есть какой-то fallback на этот случай, кроме варнинга в лог?Perruer Автор
Спасибо, оба вопроса по делу.
Про кодогенерацию. Номера полей мешают только перекодированию на уровне байтов. Генератор, который сопоставляет поля по имени, был бы от этого защищён, так что идея рабочая. Я не пошёл на неё потому, что различия не только в номерах: вложенные атрибуты (nested_type) есть только в v6, и эту ветку всё равно пришлось бы писать руками. На ~300 строк генератор с исключениями выходит дороже самих адаптеров. Terraform и OpenTofu, насколько я знаю, тоже держат для v5 и v6 два отдельных конвертера, написанных вручную. Настоящий риск в другом: протокол обновится, а адаптер тихо пропустит новое поле. От этого лучше защищает не генератор, а тест, который сверяет поля обоих .proto с тем, что адаптеры реально переносят, и падает на незнакомом поле. Добавлю такой.
Про maxFixRounds. Вы нашли дыру, и она хуже, чем вы предположили: предупреждения на этот случай нет. Если 16 раундов кончились, цикл выходит молча, и ресурс остаётся с ошибкой до tofu plan. Цепочки вида «A конфликтует с B, после удаления A — C с B» цикл проходит: каждый шаг — один раунд. На реальных ресурсах, которые я гонял, хватало 1–3 раундов, но 16 — число с потолка. Исправлю так:
число раундов — по количеству заданных необязательных аргументов: каждый раунд убирает один, так что цикл гарантированно закончится, а искусственный предел не нужен;
в первую очередь убирать аргументы с нулевыми значениями — именно их пишет в state старый SDK, и из-за них чаще всего конфликты;
если ошибки остались, ресурс не отдаётся молча: над ним в сгенерированном файле появляется комментарий с текстом ошибок провайдера, плюс итоговый список таких ресурсов в конце запуска.
KrimsN
Про молчаливый выход из
maxFixRounds— рад, что пригодилось, это как раз тот случай, когда баг всплывает не в тестах, а на проде у кого-то ещё через полгода ).По новому плану на «убирать сначала нулевые значения»: а как вы отличаете «реально нулевое, потому что unset» от «пользователь осознанно поставил
0/false/""» в SDKv2-состоянии? Если я правильно понял суть проблемы из статьи, стейт старого SDK в принципе не хранит эту разницу — тогда эвристика «нулевые первыми» может задеть валидные явные нули. Или на практике это не встречается, потому что осмысленный 0 обычно идёт в Required-поле, а не в Optional, и до вашего цикла просто не доходит?И по тесту, сверяющему поля адаптеров с
.proto— он у вас будет генерироваться из самих proto-файлов (structural diff по номерам полей), или руками поддерживаемый список ожидаемых полей на каждую версию протокола? Второе тоже сработает, но первое само себя обновит при апгрейде terraform-plugin-go.Perruer Автор
По нулям вы правы: из state старого SDK «не задано» и «явно 0» не различить. SDKv2 пишет нулевое значение в обоих случаях, а в схеме протокола нет даже значения по умолчанию (
Default), так что провайдер нам ничего не подскажет.Но эвристика не решает, что удалять, — она только выбирает порядок. Цикл трогает лишь аргументы, на которые провайдер вернул ошибку валидации. Необязательный
desired_count = 0без конфликта до цикла просто не доходит, и неважно, осмысленный он или нет. Обязательные поля не удаляются никогда. «Нулевые первыми» решает только, какой из отклонённых аргументов убрать раньше. Типичный случай — пара сConflictsWith, где один аргумент нулевой, а второй нет. Явный ноль здесь почти исключён: SDKv2 проверяетConflictsWithпо сырому конфигу, и явный0рядом с заданным партнёром не прошёл бы валидацию ещё у самого пользователя. Раз ресурс существует, такой пары в его конфиге не было, и нулевой член пары — артефакт state.Где эвристика реально может ошибиться: оба аргумента нулевые или оба нет (тогда порядок ничего не меняет), и поля с ненулевым
Default, которые мы не видим. Если убрать явный0у поля сDefault = 5, провайдер подставит 5, иplanпокажет изменение. От этого страхует не эвристика, а проверка результата. В CI есть e2e: Unclick импортирует набор ресурсов из moto, иtofu planдолжен показать только импорт — без изменений, созданий и удалений. Так что ваш сценарий упадёт там, а не у пользователя через полгода. Хорошее дополнение: в итоговом списке показывать, какие аргументы были убраны, чтобы при расхождении вplanбыло видно, откуда оно.По тесту — гибрид. Множество полей тест будет брать из самих .proto автоматически, через дескрипторы сгенерированных Go-пакетов (
protoreflect). Сравнивать v5 с v6 нужно по именам, а не по номерам: номера расходятся намеренно, это и была исходная проблема. А решение «это поле переносим или сознательно пропускаем» автоматизировать нельзя: это смысл, а не структура. Поэтому рядом лежит список пропускаемых полей. Тест падает, если в дескрипторе появилось поле, которого нет ни среди перенесённых, ни среди пропускаемых. При обновлении протокола он сам найдёт новые поля — ровно то, что вы описали, — но требует от человека решения по каждому. Одна оговорка: .proto у нас не подтягиваются из terraform-plugin-go, а скопированы в репозиторий, как советует сам HashiCorp в шапке файла («copy this definition into your own codebase»). Значит, обновление — это копирование новой версии и перегенерация, и тест срабатывает на этом шаге.