
Привет, Хабр! На связи Илья Антипов, разработчик Рег.облака в группе Рунити. В этой статье расскажу, как мы поднимали наш 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 будут очень кстати в комментариях.