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

Нам потребовались:

  1. Валидный SSL-сертификат на хост, где доступен Terralist
    Самоподписанный сертификат тоже возможен, но тогда его нужно доверить на стороне клиента (OS / Terraform). В статье я не разбираю этот кейс подробно, фиксируем только факт: Terralist должен быть доступен по HTTPS.

  2. OIDC-провайдер для авторизации Terralist
    OpenID Connect (OIDC) — это надстройка над OAuth 2.0 для аутентификации пользователей и сервисов. Нам нужен любой OIDC-провайдер, который умеет выдавать токены: в примере — Keycloak.

  3. Возможность хранить секреты для 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 в Terralist: задаем имя и, при необходимости, политику.
Создание authority в Terralist: задаем имя и, при необходимости, политику.

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

Добавление GPG-ключа к authority: указываем ID и ASCII-armor публичного ключа, экспортированного ранее.
Добавление GPG-ключа к authority: указываем ID и ASCII-armor публичного ключа, экспортированного ранее.

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

Создание API-ключа для authority: задаем имя, по которому потом будет удобно его найти.
Создание API-ключа для authority: задаем имя, по которому потом будет удобно его найти.

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

Просмотр созданного API-ключа — значение нужно передать в TERRALIST_API_KEY при запуске скрипта загрузки манифеста.
Просмотр созданного API-ключа — значение нужно передать в 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 — более подробный лог.

  1. После сборки в ./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 будут очень кстати в комментариях.

Комментарии (0)