
Привет, Хабр! На связи Илья Антипов, разработчик Рег.облака в группе Рунити. В этой статье расскажу, как мы поднимали наш Terraform Registry для размещения Terraform-провайдера. Какие ограничения уперлись в официальный HashiCorp Registry, почему выбрали Terralist, как настроили авторизацию через Keycloak и автоматизировали сборку релизов с помощью GoReleaser — об этом подробно расскажу в статье.
Если вы когда-нибудь пытались собрать свой провайдер или поднять альтернативный registry, этот текст сэкономит вам несколько часов или даже дней.
Дисклеймер: все примеры в статье основаны на Unix-подобных системах (Linux, macOS). На Windows процесс может отличаться.
Навигация по тексту
Зачем нам собственный Terraform Registry
Terraform — это инструмент Infrastructure as Code (IaC), который описывает инфраструктуру в виде кода и управляет ее жизненным циклом.
Terraform-провайдер — плагин, через который Terraform общается с вашим API.
Terraform Registry — хранилище провайдеров и модулей, откуда Terraform их скачивает.
Мы начали работу над собственным Terraform-провайдером для IaaS (Infrastructure as a Service, инфраструктура как сервис) в Рег.облаке и довольно быстро уперлись в ограничения официального HashiCorp Registry:
региональные ограничения и санкции;
нестабильный доступ;
невозможность оперативно выкатывать обновления.
Кроме этого были чисто практические причины.
1. Контроль скорости доставки
Провайдер должен обновляться синхронно с API. Свой registry = обновление доступно сразу после релиза, без посредников.
2. Нормальный DX (developer experience)
Да, мы могли бы заставить пользователей вручную скачивать бинарники и класть их в нужные директории. Но нормальный путь — дать им:
provider "regcloud" { source = "tf.reg.cloud/regru/regcloud" }
и всё работает.
3. Независимость архитектуры
Terraform Registry от HashiCorp сейчас — зона турбулентности для многих российских компаний. Свой registry позволяет сохранять архитектуру, ни от кого не завися.
Почему мы выбрали Terralist
Из доступных решений мы остановились на Terralist — это open-source реализация API Terraform Registry именно для провайдеров.
Terralist оказался наиболее подходящим решением по ряду технических причин:
разворачивается быстро и предсказуемо, без сложной подготовки окружения;
поддерживает провайдеры Terraform (большинство альтернатив работают только с модулями);
корректно отдает информацию о версиях, поддерживаемых платформах и манифестах;
остается достаточно легким, чтобы интегрировать его в существующую инфраструктуру без серьезных изменений.
При этом важно учитывать архитектурную особенность Terralist: он не хранит ZIP-артефакты провайдера. Сервис отдает только метаданные (version metadata JSON). Файлы провайдера нужно размещать отдельно — на HTTPS-сервере или в S3-совместимом хранилище.
Требования к окружению
Перед тем как что-то поднимать, полезно зафиксировать минимальный набор требований. Terraform принудительно требует HTTPS для загрузки провайдера, а еще ожидает строго определенную структуру директорий, файлов и подписи.
Нам потребовались:
Валидный SSL-сертификат на хост, где доступен Terralist
Самоподписанный сертификат тоже возможен, но тогда его нужно доверить на стороне клиента (OS / Terraform). В статье я не разбираю этот кейс подробно, фиксируем только факт: Terralist должен быть доступен по HTTPS.OIDC-провайдер для авторизации Terralist
OpenID Connect (OIDC) — это надстройка над OAuth 2.0 для аутентификации пользователей и сервисов. Нам нужен любой OIDC-провайдер, который умеет выдавать токены: в примере — Keycloak.Возможность хранить секреты для GPG
GPG (GNU Privacy Guard) — инструмент для асимметричного шифрования и электронной подписи. Terraform требует, чтобы checksum-файлы провайдеров были подписаны GPG-ключом. Переменная окружения GNUPGHOME задает директорию, где GPG хранит приватные ключи.
Предварительные шаги: создаем GPG-ключи
Начнем с подготовки GPG-ключа, который будет подписывать checksums провайдера. Выбираем директорию для хранения ключей:
export GNUPGHOME="$PWD/gpg-secrets"
Генерируем приватный ключ:
gpg --batch --generate-key <<EOF %no-protection Key-Type: RSA Key-Length: 4096 Key-Usage: sign Name-Real: Your-company Name-Email: your-email@example.com Expire-Date: 1y %commit EOF
Здесь:
Name-Real — произвольное имя, можно указать название компании;
Name-Email — рабочий email, по которому вы потом будете находить ключ;
Expire-Date — срок действия ключа.
Дальше экспортируем публичный ключ — он понадобится на этапе добавления GPG-ключа к authority в Terralist:
gpg --armor --export your-email@example.com
Скопируйте вывод команды — это значение потребуется в поле ASCII Armor в интерфейсе Terralist.
Поднимаем Terralist, Keycloak и Nginx
Теперь разворачиваем окружение. Ниже — упрощенный docker-compose.yml, который поднимает:
terralist — наш Terraform Registry;
keycloak — OIDC-провайдер для аутентификации/авторизации;
nginx — статический сервер для ZIP-архивов и checksum-файлов.
version: '3.8' services: terralist: image: ghcr.io/terralist/terralist:latest container_name: terralist ports: - "0.0.0.0:5758:5758" environment: - TERRALIST_DATABASE_BACKEND=sqlite - TERRALIST_SQLITE_PATH=/tmp/db/terralist.db - TERRALIST_OAUTH_PROVIDER=oidc - TERRALIST_OI_CLIENT_ID=terralist-client - TERRALIST_TOKEN_SIGNING_SECRET=terralist-secret - TERRALIST_OI_CLIENT_SECRET=terralist-secret - TERRALIST_OI_TOKEN_URL=http://keycloak:8080/realms/terralist/protocol/openid-connect/token - TERRALIST_OI_USERINFO_URL=http://keycloak:8080/realms/terralist/protocol/openid-connect/userinfo - TERRALIST_OI_AUTHORIZE_URL=http://localhost:8080/realms/terralist/protocol/openid-connect/auth - TERRALIST_LOG_LEVEL=info - TERRALIST_COOKIE_SECRET=terralist-secret - TERRALIST_PROVIDERS_ANONYMOUS_READ=true volumes: - ./db/:/tmp/db depends_on: - keycloak restart: unless-stopped nginx: image: nginx:alpine container_name: nginx-static-simple ports: - "0.0.0.0:8002:80" volumes: - ./docker-compose.d/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./dist:/usr/share/nginx/html:ro restart: unless-stopped keycloak: image: quay.io/keycloak/keycloak:latest command: start-dev --import-realm environment: KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin KC_HOSTNAME: localhost KC_HOSTNAME_PORT: 8080 KC_HOSTNAME_STRICT: "false" volumes: - ./docker-compose.d/keycloak-config:/opt/keycloak/data/import ports: - "8080:8080" volumes: mock_openid_data:
Статические файлы провайдера (*.zip, _SHA256SUMS, .sig) будут лежать в директории ./dist, которую nginx отдает как /.
Структура файлов
Минимальная структура проекта выглядит так:
docker-compose.yml docker-compose.d/ keycloak-config/ terralist-realm.json nginx/ nginx.conf dist/ ... здесь будут ZIP-архивы и checksum-файлы ... scripts/ release-json.sh upload-manifest-json.sh
Конфигурация Keycloak (realm для Terralist)
Создадим realm для Terralist. Файл docker-compose.d/keycloak-config/terralist-realm.json:
{ "realm": "terralist", "enabled": true, "attributes": { "frontendUrl": "http://localhost:8080" }, "clients": [ { "clientId": "terralist-client", "enabled": true, "secret": "terralist-secret", "protocol": "openid-connect", "publicClient": false, "redirectUris": [ "http://localhost:5758/*", "http://localhost:5758/auth/callback" ], "webOrigins": ["*"] } ], "users": [ { "username": "admin", "enabled": true, "credentials": [ { "type": "password", "value": "admin123" } ] } ] }
Здесь:
realm— отдельное пространство авторизации для Terralist;clientId / secret— клиент для самого Terralist;users— тестовый пользователь для входа в веб-интерфейс.
Конфигурация Nginx
В примере Nginx раздает артефакты по HTTP (локально). Для реального использования Terraform нужны HTTPS-URL для скачивания провайдера, поэтому включите SSL-терминацию или поставьте Nginx/Ingress с сертификатом перед статикой docker-compose.d/nginx/nginx.conf:
events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html index.htm; location / { autoindex on; try_files $uri $uri/ =404; } } }
Terraform скачивает провайдер по HTTPS, поэтому для продакшена добавьте SSL-терминацию ( listen 443 ssl; и сертификаты) или поставьте TLS перед Nginx (Ingress / LB). HTTP-конфиг ниже подходит для локальной проверки.
Первичная настройка Terralist
Для входа в веб-интерфейс Terralist используем учётные данные, заданные в конфигурации выше:
логин: admin
пароль: admin123
Сначала создаем authority — сущность, через которую Terralist будет доверять подписи нашего провайдера. Имя authority мы будем использовать дальше при генерации metadata и при загрузке версии провайдера через HTTP API Terralist.

После этого добавляем к authority GPG-ключ, которым подписываем checksum-файлы. В Key ID указываем идентификатор ключа, а в поле ASCII Armor — публичный GPG-ключ, экспортированный в главе «Предварительные шаги: создаем GPG-ключи».

Дальше создаем API-ключ — он понадобится скрипту upload-manifest-json.sh, чтобы загружать metadata для провайдера через HTTP API Terralist.

Значение API-ключа можно посмотреть в интерфейсе Terralist и скопировать. Его мы передаем в скрипт через переменную TERRALIST_API_KEY.

Сборка и подпись провайдера через GoReleaser
Ручная сборка ZIP-архивов и подписьchecksums — самый хрупкий этап. Мы сразу перевели этот этап на GoReleaser — на этом шаге мы только собираем и подписываем артефакты, без взаимодействия с Terralist.
Ниже — наш .goreleaser.yaml.
Важно: префикс имени провайдера в project_name должен начинаться с terraform-provider- — это требование Terraform. Менять можно только хвост, после префикса.
# .goreleaser.yaml version: 2 project_name: terraform-provider-habrprovider env: - GO111MODULE=on builds: - id: provider main: ./cmd binary: "{{ .ProjectName }}_v{{ .Version }}" goos: - darwin - linux - windows goarch: - amd64 - arm64 goarm: - 7 ignore: - goos: windows goarch: arm64 flags: - -trimpath ldflags: - -s -w -X main.version={{.Version}} archives: - id: main-zip name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" formats: [zip] files: - none* checksum: name_template: "{{ .ProjectName }}_v{{ .Version }}_SHA256SUMS" algorithm: sha256 signs: - cmd: gpg signature: "${artifact}.sig" artifacts: checksum release: github: owner: user name: repo draft: true skip_upload: true disable: true header: | ## Terraform Provider CloudRegru Terraform provider for CloudRegru services. footer: | ## Documentation Example documentation changelog: sort: asc groups: - title: Features regexp: "^.*?feat(\\([^\\)]+\\))?!?:.+$" order: 0 - title: Bug Fixes regexp: "^.*?fix(\\([^\\)]+\\))?!?:.+$" order: 1 - title: Others order: 999 nfpms: [] snapcrafts: [] publishers: - cmd: ./scripts/release-json.sh env: - VERSION={{ .Version }} - WORK_DIR=./dist name: Generate Terraform Registry Metadata
Ключевые моменты:
binary: "{{ .ProjectName }}_v{{ .Version }}"— имя бинарника с версией;archives.name_template— имя ZIP-архива: Terraform ожидает строгий формат;checksum— GoReleaser генерирует*_SHA256SUMS;signs— подпись checksum-файла через GPG;publishers— вызывает наш скриптrelease-json.sh, который формирует metadata JSON для Terralist.
Скрипт генерации metadata для Terralist
Terraform Registry для провайдера требует JSON-манифест, в котором описаны:
поддерживаемые протоколы;
URL checksum-файла и подписи;
платформы (OS / Arch) и ссылки на ZIP-архивы.
Скрипт scripts/release-json.sh:
#!/bin/bash set -e WORK_DIR=${WORK_DIR:-.} cd "$PWD/$WORK_DIR" VERSION=${VERSION#v} # название провайдера PROVIDER_NAME="habrprovider" # URL Terralist BASE_URL="http://localhost:5758" echo "Generating Terraform registry metadata for version ${VERSION}..." MAIN_ZIP="terraform-provider-${PROVIDER_NAME}_v${VERSION}_darwin_arm64.zip" if [[ -f "terraform-provider-${PROVIDER_NAME}_v${VERSION}_SHA256SUMS" ]]; then MAIN_SHASUM=$(grep "$MAIN_ZIP" "terraform-provider-${PROVIDER_NAME}_v${VERSION}_SHA256SUMS" | cut -d' ' -f1) else echo "Error: SHA256SUMS file not found:" "$PWD" "terraform-provider-${PROVIDER_NAME}_v${VERSION}_SHA256SUMS" exit 1 fi PLATFORMS_JSON="" for file in "terraform-provider-${PROVIDER_NAME}_${VERSION}_"*.zip; do if [[ "$file" == "terraform-provider-${PROVIDER_NAME}_${VERSION}_*.zip" ]]; then continue fi if [[ "$file" == "terraform-provider-${PROVIDER_NAME}_v${VERSION}.zip" ]]; then continue fi filename=$(basename "$file") os_arch=$(echo "$filename" | sed "s/terraform-provider-${PROVIDER_NAME}_${VERSION}_//" | sed 's/.zip//') os=$(echo "$os_arch" | cut -d'_' -f1) arch=$(echo "$os_arch" | cut -d'_' -f2) # Handle windows .exe files if [[ "$os" == "windows" ]]; then arch=$(echo "$os_arch" | cut -d'_' -f2) fi # Get shasum for this file shasum=$(grep "$filename" "terraform-provider-${PROVIDER_NAME}_v${VERSION}_SHA256SUMS" | cut -d' ' -f1) # Add to platforms array if [[ -n "$PLATFORMS_JSON" ]]; then PLATFORMS_JSON="$PLATFORMS_JSON," fi PLATFORMS_JSON="$PLATFORMS_JSON { \"os\": \"$os\", \"arch\": \"$arch\", \"download_url\": \"${BASE_URL}/$filename\", \"shasum\": \"$shasum\" }" done # Create the final JSON cat > version_metadata.json << EOF { "protocols": [ "4.0", "5.1" ], "shasums": { "url": "${BASE_URL}/terraform-provider-${PROVIDER_NAME}_v${VERSION}_SHA256SUMS", "signature_url": "${BASE_URL}/terraform-provider-${PROVIDER_NAME}_v${VERSION}_SHA256SUMS.sig" }, "platforms": [${PLATFORMS_JSON} ] } EOF echo "✅ Generated version_metadata.json" echo "=== version_metadata.json ===" cat version_metadata.json
Идея простая:
вычитываем
SHA256для каждого ZIP-архива;формируем массив
platforms;собираем
version_metadata.jsonв формате, который ожидает Terraform.
Скрипт загрузки metadata в Terralist
Теперь загрузим сформированный metadata-файл в Terralist через HTTP API.
scripts/upload-manifest-json.sh: #!/bin/bash if [[ "$1" = "-f" ]]; then force=true shift 1 else force=false fi read -s -p "Enter TERRALIST_API_KEY: " TERRALIST_API_KEY # URL Terralist (указан в docker-compose) URL=http://localhost:5758 # Имя authority, созданной в Terralist AUTHORITY="example_authority" file="./dist/version_metadata.json" version=cat "$file" | jq -r ".platforms[0].download_url" | sed -r 's/.*([0-9]+.[0-9]+.[0-9]+).*/\1/g' if $force; then curl -v -X DELETE "$URL/v1/api/providers/${AUTHORITY}/${version}/remove" \ -H "Authorization: Bearer x-api-key:$TERRALIST_API_KEY" fi curl -v -X POST "$URL/v1/api/providers/${AUTHORITY}/${version}/upload" \ -H "Authorization: Bearer x-api-key:$TERRALIST_API_KEY" \ -d "$(cat $file)"
На этом шаге мы публикуем версию провайдера в Terralist: загружаем version_metadata.json через HTTP API.
Здесь:
TERRALIST_API_KEY— API-ключ, который вы создаете в веб-интерфейсе Terralist для своей authority;-f— опциональный флаг, чтобы удалить существующую версию перед загрузкой новой;AUTHORITY— имя authority в Terralist, созданной на шаге «Первичная настройка Terralist». Оно используется в API-пути при загрузке и удалении версий провайдера.
Как запускать пайплайн
Теперь соберем всё вместе. Экспортируем GNUPGHOME (директория с GPG-ключами):
export GNUPGHOME="$PWD/gpg-secrets"
Инициализируем git-репозиторий (или используем существующий), коммитим изменения и создаем тег:
git init # если репозиторий еще не инициализирован git add . git commit -m "Initial release" git tag v0.1.0
Для GoReleaser важно, чтобы все изменения были закоммичены, и была создана версия-тег.
Запускаем GoReleaser:
goreleaser release --snapshot --clean --verbose
Здесь:
--snapshot — можно убрать в продакшене, чтобы делать полноценные релизы;
--clean — очистит директорию сборки перед запуском;
--verbose — более подробный лог.
После сборки в
./distпоявятся:ZIP-архивы для всех платформ;
_SHA256SUMSи .sig;version_metadata.json.
Загружаем metadata в Terralist: ./scripts/upload-manifest-json.sh
Скрипт запросит TERRALIST_API_KEY и отправит metadata для нужной версии провайдера.
После этого terraform init при конфигурации вида:
terraform { required_providers { regcloud = { source = "<you_https_hostname>/<authority>/<name_uploaded_provider>" version = "0.1.0" } } } provider "regcloud" { # конфигурация доступа к вашему IaaS API }
сможет скачать провайдер из вашего Terralist.
Оставшиеся вопросы и ограничения
Есть несколько мест, которые мы оставили за рамками этой статьи, но которые важно учитывать:
Автоматическая загрузка ZIP-архивов в хранилище
Теоретически Terralist может работать вместе с S3-совместимым хранилищем и сам публиковать артефакты. В нашем прототипе это пока не реализовано: ZIP-архивы складываются в ./dist, а nginx раздает их как статику.
SSL для Terralist
В примере выше Terralist доступен по HTTP (для локальных тестов). Для боевого сценария его нужно либо спрятать за nginx с SSL, либо настроить SSL прямо в контейнере Terralist.
Без HTTPS Terraform просто откажется работать с Registry.
Хранение GPG-секретов в CI/CD
В статье мы подробно не разбираем этот вопрос, но в реальном пайплайне GPG-ключи нужно хранить в секрет-хранилище (Vault, GitLab CI/CD variables, GitHub Actions Secrets и т. п.) и поднимать GNUPGHOME уже в раннере.
Заключение
Создание собственного Terraform Registry — задача, которая кажется простой только в теории. На практике она состоит из десятков нюансов: от подписи checksums до OpenID-конфигурации и правильного пути до ZIP-архивов. Мы прошли этот путь, описали все шаги и собрали инструкции — чтобы у вас на это ушла не неделя, а один вечер.
Если вам интересно, как устроен наш Terraform-провайдер для IaaS, какие ресурсы он поддерживает и как его использовать — мы готовим отдельную статью. Вопросы, замечания и истории о вашем Registry будут очень кстати в комментариях.
