CNN TürkMASTERCHEF ELEME ADAYLARI 24 EYLÜL: MasterChef'te dokunulmazlık oyununu kim kazandı?The Jerusalem PostKKL-JNF unveils archival photos showcasing Sukkot celebrations in honor of the holidayPunchEx-PDP, ADC supporters target 100,000 votes for Tinubu, YayiInquirerFarmer with ‘shabu’ caught at Ilocos Sur checkpointESPN Deportes¡En vivo! Tercera práctica en el GP de AzerbaiyánBollywood HungamaLove & War: First look of Alia Bhatt revealed; actress stuns in glamorous cabaret-inspired avatarUOLAnvisa proíbe propaganda da caneta emagrecedora Semavy após anúncios irregularesDaily MailThe Latin American street gang recruiting children in the UK: Feared 'Los Trinitarios' is now linked to three London knife killings... with machetes their weapon of choice to target victimsRTL BoulevardDakota Johnson noemt samenwerking met Taylor Swift 'geweldig'ESPNTransfer value tiers: Which clubs have the most valuable players?Globo EsporteAustrália x Brasil - Amistosos da Seleção Brasileira 2026 - Ao vivo - globoesporte.comPremium TimesCSOs demand disclosure of JBS $2.5bn Nigeria deal, warn of livestock expansion risks
The Daily Newsstand · Free, Always
Friday, September 25, 2026

Из ClickOps в код: как я оживил Terraformer и научил его говорить с провайдерами напрямую

Translate

Почти у каждой команды есть «накликанная» инфраструктура. Кто-то когда-то создал 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, я взял его как есть.

Для импорта нужна небольшая часть протокола:

Вызов

Зачем

GetProviderSchema

Схема: какие есть ресурсы, какие у них атрибуты и блоки

ConfigureProvider

Передать регион, токен и прочие настройки

ReadResource

Прочитать объект из облака по ID и известным атрибутам

ImportResourceState

Превратить ID в начальное состояние ресурса

ValidateResourceConfig

Проверить конфиг, как это делает tofu validate

ListResource

Перечислить существующие объекты (новое, о нём ниже)

Значения ходят в поле 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

write_only

nested_type

11

deprecation_message

write_only

12

—

deprecation_message

Перекодировка тихо превратила бы флаг «только для записи» во вложенный тип. Поэтому адаптеров два, для каждой версии свой. Дублирования вышло строк на двести, зато никакой магии.

Живая проверка:

Провайдер

Версия

Протокол

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 — на настоящем аккаунте, только чтение. Импорт идёт через токен, план тоже только читает.

Результаты:

Сценарий

Итог tofu plan

unclick import github, личный аккаунт

48 to import, 0 to change

unclick import aws на moto

12 to import, 0 to change

unclick scan aws на moto

7 to import, 0 to change

unclick import kubernetes на kind

3 to import, одно изменение wait_for_rollout

Всё это крутится в 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, напишите, какие провайдеры вам нужнее всего: буду проверять и чинить их в первую очередь.

Проект открытый и бесплатный. Если он вам пригодится, поддержать разработку можно на Boosty.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.