Я сделаю небольшое заявление, а затем его обосную. Ни один сокращатель ссылок на данный момент не предоставляет первоклассный Terraform-провайдер. Bitly, TinyURL, Rebrandly, Short.io, Dub.co - все пять публикуют REST API, некоторые поддерживают вебхуки, но ни у кого нет terraform-provider-*. На GitHub существует созданный сообществом провайдер для Bitly v3 API; он не поддерживается и покрывает от силы четверть возможностей API. Это тот самый пробел, который мы решили восполнить.
Несколько недель назад мы решили закрыть этот вопрос. Результатом стал terraform-provider-elido, который API Elido теперь предоставляет в виде ресурса elido_link, источника данных elido_workspace и (пока что) источника данных elido_custom_domain - подробнее об этом ниже. Далее мы разберем, что именно вошло в релиз, какие инженерные решения мы приняли и что сознательно решили не включать в v0.1.0. Провайдер доступен под открытым кодом (та же лицензия, что и у остальной части Elido) и находится в директории tools/terraform-provider-elido/.
Почему коротким ссылкам место в Terraform#
Аргумент прост. Если вы запускаете маркетинговые редиректы, у вас наверняка уже есть другие части инфраструктуры, связанные с той же кампанией:
- DNS-запись Cloudflare, указывающая на лендинг.
- S3-бакет и дистрибуция CloudFront, обслуживающая этот лендинг.
- Сервис Lambda или Cloud Run, генерирующий подписанные URL.
- Тег кампании, встроенный в Google Tag Manager или Segment.
В 2026 году все эти пять компонентов управляются через Terraform. Но короткая ссылка, которая находится в самом начале воронки - та самая точка входа, на которую нажимает пользователь - часто лежит в Google Sheets. Этот разрыв и порождает дрейф (drift). Лендинг выводится из эксплуатации, а ведущий на него редирект продолжает жить, собирая 404-е ошибки, пока кто-нибудь не напишет в Slack маркетингу.
Этот пробел можно устранить двумя способами. Можно написать скрипт-прослойку на TypeScript, который свяжет вывод Terraform с нашим REST API. Это рабочий вариант; у нас есть клиенты, которые делают именно так. Или же мы можем дать вам настоящий Terraform-провайдер, где редирект будет блоком resource рядом с вашей записью Cloudflare, а terraform plan и terraform destroy будут знать о нем так же, как и обо всем остальном. Мы выбрали второй путь. Первый вы и так могли реализовать сами.
Что умеет terraform-provider-elido сегодня#
Минимальный набор возможностей v0.1.0 на языке HCL:
terraform {
required_providers {
elido = {
source = "elidoapp/elido"
version = "~> 0.1"
}
}
}
provider "elido" {
# api_url defaults to https://api.elido.app
# api_token reads ELIDO_API_TOKEN
}
data "elido_workspace" "main" {
id = 42
}
data "elido_custom_domain" "links" {
workspace_id = data.elido_workspace.main.id
hostname = "links.example.com"
}
resource "elido_link" "spring_campaign" {
workspace_id = data.elido_workspace.main.id
domain_id = data.elido_custom_domain.links.id
slug = "spring-2026"
destination_url = "https://example.com/landing/spring"
title = "Spring 2026 email campaign"
tags = ["spring-2026", "email"]
redirect_status = 301
}
terraform apply - и готово. Обнаружение дрейфа работает на основе полей, которые API возвращает в ответ. Переименование метки ресурса Terraform или изменение slug на лету не приводит к пересозданию ресурса - провайдер отправляет PATCH-запрос к тому же числовому ID. Изменение workspace_id или domain_id вызывает принудительную замену (force replacement), потому что в этот момент речь идет уже о другом пограничном (edge) маршруте. Это логичный жизненный цикл, к которому подталкивает plugin framework от HashiCorp.
Возможность массового развертывания - это то, что оправдывает работу для большинства команд:
locals {
channels = ["email", "twitter", "linkedin", "reddit", "hn"]
regions = ["us", "eu", "apac", "latam"]
}
resource "elido_link" "campaign_launch" {
for_each = {
for pair in setproduct(local.channels, local.regions) :
"${pair[0]}-${pair[1]}" => pair
}
workspace_id = data.elido_workspace.main.id
domain_id = data.elido_custom_domain.links.id
slug = "launch-${each.key}"
destination_url = "https://example.com/launch?ch=${each.value[0]}&r=${each.value[1]}"
tags = ["launch-2026", each.value[0], each.value[1]]
}
Двадцать ссылок - один apply. Удалили блок - двадцать удалений, один apply. Примерно такой сценарий всплывал в заметках после трех звонков с клиентами в прошлом квартале: маркетингу нужны ссылки с UTM-метками для каждого канала и региона для запуска, инженеры каждый раз пишут скрипт для переноса из Google Sheets в API, скрипт устаревает, а его автор увольняется. Сила Terraform здесь не в новизне, а в том, что мы делаем этот процесс скучным и предсказуемым.
Полное руководство со справочником атрибутов и примерами импорта доступно по адресу /docs/guides/terraform. Исходный код провайдера поставляется с файлом examples/main.tf, который представляет собой более сложную версию приведенного выше фрагмента.
Как устроен провайдер#
Примерно 600 строк кода на Go, из которых около 200 - это определения схем. Структура:
tools/terraform-provider-elido/
├── main.go # plugin entrypoint
├── internal/provider/
│ ├── provider.go # config + auth
│ ├── link_resource.go # CRUD + import
│ ├── workspace_data_source.go # GET /v1/workspaces/{id}
│ ├── custom_domain_data_source.go # GET /v1/workspaces/{id}/domains
│ ├── helpers.go # tag conversion
│ └── provider_test.go # 7 unit tests
├── go.mod # depends on packages/sdk-go
├── .goreleaser.yml # signed-checksum builds
├── terraform-registry-manifest.json # protocol_versions: ["6.0"]
├── Makefile # build + install-local + testacc
└── examples/main.tf
Несколько решений, о которых стоит упомянуть.
Мы используем plugin framework, а не устаревший SDK. В 2023 году HashiCorp явно направила разработчиков новых провайдеров на использование terraform-plugin-framework. Большинство популярных провайдеров (aws, cloudflare, google) находятся в процессе миграции; небольшие и новые проекты создаются сразу на базе фреймворка. Создание нового провайдера на базе старого SDK означало бы планирование задачи по миграции сразу после релиза. Мы избежали миграции, просто не создавая для нее причин. У фреймворка более строгая типизация, реальная валидация схем на уровне протокола плагина и гораздо более чистая модель планирования (PlanModifiers вместо колбэков CustomizeDiff). Для небольшого провайдера разница в эргономике огромна.
Провайдер не дублирует SDK. Каждый метод ресурса делегирует выполнение packages/sdk-go - тому же самому SDK, который мы публикуем для обычных Go-интеграций. Провайдер по задумке является тонким адаптером между Schema и SDK. Это имеет два последствия. Хорошее: любой баг, исправленный в SDK, автоматически исправляется в провайдере. Плохое: любой пробел в SDK - это пробел в провайдере. Честный пример - кастомные домены. api-core пока не предоставляет POST/DELETE для /v1/workspaces/{id}/domains; путь записи реализован в domain-manager и доступен через панель управления. Пока api-core не начнет проксировать эти запросы, в SDK не будет Domains.Create, а в провайдере не будет ресурса elido_custom_domain - только источник данных для поиска по имени хоста. Мы устраним этот пробел в v0.2.0; прокси-слой - это задача на неполную неделю, а PR для SDK и провайдера уже набросан.
Авторизация устроена так же, как в любом другом клиенте Elido. Токен API передается в заголовке Authorization, с возможностью использования переменной окружения ELIDO_API_TOKEN. Мы не открываем авторизацию через куки или X-Dev-User-ID в провайдере; это удобства для локальной разработки, которым не место в IaC, где конфигурация хранится в системе контроля версий и запускается в CI. У вашего CI либо есть токен, либо его нет.
Обнаружение дрейфа: часть, которая сложнее, чем кажется#
Если вы дочитали до этого момента, этот раздел заслуживает внимания. Диффинг в Terraform - это принципиальный вопрос: имея то, что написал пользователь (Plan), то, что сервер вернул в прошлый раз (State), и то, что сервер возвращает сейчас (Read), что именно мы должны предложить сделать?
Для такого ресурса, как elido_link, три момента делают эту задачу нетривиальной:
Поля Optional + Computed со значениями по умолчанию на стороне сервера. Пользователь может не указывать redirect_status. Сервер подставляет 302. При следующем вызове Read возвращается 302. Без должной осторожности это будет выглядеть как дрейф при каждом планировании: «Я ничего не просил, получил 302, предлагаю снова сбросить в "ничего"». Фреймворк предоставляет модификатор плана UseStateForUnknown, который говорит: «Если у меня нет запланированного значения, сохрани то, что есть в состоянии». Мы используем его для каждого поля, имеющего значение по умолчанию на сервере. Это звучит просто, но именно здесь чаще всего возникают ошибки в экосистеме провайдеров («provider produced inconsistent result after apply»).
Теги с нормализацией на стороне сервера. Наш API хранит теги как множество (set); Terraform видит их как упорядоченный список. На данный момент мы не решаем эту проблему радикально. Сервер сохраняет порядок при ответе, поэтому на практике дифф остается стабильным, но пользователь, который изменит порядок тегов в HCL, увидит обновление, не вносящее изменений (no-op update). Это корректное поведение; альтернатива - неявная сортировка на входе - означала бы, что terraform plan и terraform apply расходятся в оценке изменений, что является «смертным грехом» в Terraform. Мы вернемся к этому вопросу, если клиенты начнут жаловаться. Руководство по лучшим практикам от HashiCorp твердо стоит на позиции «не делай ничего неожиданного».
Статус как три-состояние. Ссылка может быть active, paused или archived. Установка status = "paused" в HCL, но не при создании (сервер по умолчанию ставит active), означает, что нам нужно отправить дополнительный PATCH-запрос внутри того же Create. Это реализовано как шаг сверки после создания - имейте это в виду, если будете читать исходный код. Альтернатива - выделение статуса в отдельный ресурс (elido_link_status, привязанный к link_id) - именно так делает AWS-провайдер для некоторых ресурсов. Мы рассматривали этот вариант, но для одного опционального поля затраты перевешивают выгоду. Если добавится еще одна настройка, требующая пост-обработки, мы пересмотрим подход.
Импорт. terraform import elido_link.spring_campaign 42:7 - это <workspace_id>:<link_id>. Мы выбрали формат с двоеточием, потому что колбэк ImportState во фреймворке передает одну строку, которую вы парсите самостоятельно. Формат <id>:<id> часто встречается в провайдерах, где ресурсы идентифицируются кортежем - см. документацию по импорту google_compute_instance для примера. Мы сознательно не перегружаем человекочитаемый slug; состояние ресурса привязано к числовому ID, и это единственное, что нужно указывать при импорте.
Тесты, CI, реестр#
Набор юнит-тестов (сейчас их 7) покрывает уровень валидации схемы и чистые функции-помощники: splitImportID, linkToModel, apiErrorString, optString. Они прогоняются за 0,5 секунды и проверяют каждый PR в рамках той же матрицы go, которая собирает наши 13 сервисов. Также есть цель testacc, которая запускает тесты против живого api-core, если установлена переменная TF_ACC=1, но это опционально: требуется токен, и мы не запускаем их на каждый коммит, так как каждый тест создает и удаляет реальную ссылку. Тестовый фреймворк HashiCorp документирует этот паттерн, и мы ему следуем.
Конвейер релизов завязан на goreleaser с точной матрицей сборки, которую ожидает Terraform Registry: linux, darwin, freebsd, windows для архитектур amd64/arm64 (плюс arm и 386 для Linux), контрольные суммы SHA256 для архивов, GPG-подпись для SHA256SUMS и файл terraform-registry-manifest.json с объявлением protocol_versions: ["6.0"]. Стоит пометить коммит тегом terraform-provider-vX.Y.Z, как ворклоу GitHub Actions запускает goreleaser release --clean, и релиз на GitHub становится доступен. Terraform Registry сам опрашивает релизы и подтягивает новую версию. Единственное, чего сейчас не хватает, - это GPG-ключа; на этой неделе мы выпускаем ключ специально для релизов провайдера, а значит, v0.1.0 появится в реестре примерно одновременно с этим постом.
Тем временем, можно установить провайдер через dev_overrides в ~/.terraformrc:
provider_installation {
dev_overrides {
"elidoapp/elido" = "/Users/<you>/.terraform.d/plugins/elidoapp/elido"
}
direct {}
}
Затем выполните make install-local в директории tools/terraform-provider-elido/, и terraform plan будет использовать локальный бинарный файл без необходимости вызова terraform init. Это официальный паттерн HashiCorp для разработки провайдеров, который отлично подходит в качестве временного способа установки до версии v1.0.0.
Чего сознательно нет в v0.1.0#
Три вещи, которые мы рассматривали, но не выпустили, и о которых хотим предупредить сразу.
Нет ресурса elido_custom_domain. Обсуждалось выше. Источника данных достаточно, чтобы передать domain_id в elido_link, а это основной сценарий использования; управление полным жизненным циклом появится позже. Ожидайте в v0.2.0, середина 2026 года.
Нет elido_folder и elido_api_key. В SDK есть и то, и другое; мы решили не добавлять схемы в v0.1.0, так как их жизненный цикл не является критической проблемой для клиентов. Папки - это организационные метаданные; API-ключи обычно создаются один раз и ротируются через панель управления. Мы добавим их, когда появится запрос.
Нет генерации кода из спецификации OpenAPI. HashiCorp выпускает terraform-plugin-codegen-openapi как бета-инструмент. Мы попробовали его на нашей спецификации; сгенерированные схемы оказались посредственными - каждое поле с поддержкой null становится Optional + Computed, каждый список - Set, и результат требует столько же доработок, сколько и написанная вручную схема, при этом его сложнее развивать. Имея всего три ресурса, ручное написание победило. Мы вернемся к генератору через полгода, когда его опробуют больше коллег.
Что сломалось в процессе разработки#
Три вещи, в которых мы ошиблись с первого раза.
Первой была работа с состоянием для Optional + Computed. Сначала мы описали title как простую строку Optional. У клиентов, которые не указывали его в HCL, создание проходило успешно, но затем каждый последующий terraform plan предлагал сбросить его в null, так как сервер сохранял пустую строку, а Terraform видел в этом дрейф. Решением стал модификатор плана UseStateForUnknown; урок в том, что интерпретация провайдером фразы «пользователь не указал значение» должна совпадать с серверным представлением о значении по умолчанию. Документация фреймворка предупреждает об этом во вступлении; в первый раз мы просмотрели это предупреждение. Записываю это здесь, чтобы избавить вас от неловкости.
Вторым был формат импорта. Изначально мы использовали <workspace_id>/<link_id> со слэшем, исходя из теории, что пути читаются естественнее. У фреймворка не было с этим проблем, а вот у HCL-линтеров и терминалов - были. Путь с двумя слэшами внутри одного аргумента командной строки в тикетах поддержки выглядит как опечатка. Мы перешли на двоеточием, которое исключает неоднозначность и соответствует конвенциям провайдера Google. Урок: строки импорта - это пользовательский интерфейс, проектируйте их как UI.
Третьим был порядок тегов. Об этом уже говорилось выше - мы решили оставить всё как есть, пока кто-нибудь не попросит об обратном. Версия, которую мы чуть не выпустили, негласно сортировала теги на входе, из-за чего terraform plan не сообщал об изменениях, когда клиент явно поменял их порядок. Это хуже, чем «шумный» дифф; мы поймали это на внутреннем тестировании. Стоит сказать об этом, потому что искушение «помочь» пользователю, нормализовав ввод, возникает постоянно при написании провайдера, и почти всегда это неверное решение.
Как использовать это с остальными частями Elido#
Провайдер - это лишь одна из форм. Остальные никуда не делись и продолжают работать:
- REST API остается первоисточником. Всё, что делает провайдер, можно сделать и через
curl. - Go SDK - это то, что провайдер использует внутри; вы можете подключить его как библиотеку.
- TypeScript и Python SDK покрывают те же возможности на языках, которые вы используете.
- GraphQL-эндпоинт позволяет выполнять те же операции чтения за один запрос, когда данные нужны в специфической форме для интерфейса.
Выбирайте то, что лучше подходит для решения вашей задачи. Terraform хорош там, где нужно управлять жизненным циклом. SDK подходит для скриптов. REST API - для разовых операций. Мы считаем, что это очевидно, и будем поддерживать все четыре варианта.
Если у вас есть любимый паттерн Terraform, который мы упустили - например, массовый импорт из CSV через for_each над блоком data "external", for_each для интеграции с Linear API или оберточный модуль для управления несколькими тенантами - откройте Issue в репозитории GitHub с меткой area:terraform. Провайдер существует для того, чтобы сделать такие паттерны привычными; мы хотим знать, какие из них до сих пор вызывают трудности.
С чего начать#
Если вы прочитали это и хотите попробовать: установите провайдер по руководству, укажите его на песочницу (sandbox workspace), опишите resource "elido_link" для редиректа, который вы всегда хотели задать кодом, и выполните terraform apply. Готовы поспорить на чашку кофе, что первой вещью, которая вас приятно удивит, будет работа terraform destroy ровно так, как вы того ожидаете.
Если вы хотите сравнить нас с альтернативами - есть подробный разбор в посте про функциональные пробелы альтернатив Bitly, а наглядное сравнение на /compare/vs-bitly показывает место Terraform в этой матрице. После выхода этого поста матрица для них стала еще короче.
- Marius
Похожее в блоге#
Попробуйте Elido
Вставьте URL - получите короткую ссылку
Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.
Бесплатно, без регистрации · 2 в день