Как написать простое расширение для Terraform на Go: минимальный SDKv2-провайдер, схема ресурса и локальный запуск

На чтение
12 мин
Дата обновления
05.11.2026

Собственный провайдер Terraform нужен не для «оформления» уже существующих конфигураций, а для добавления в Terraform новых типов объектов, с которыми CLI сможет работать как с родными. На практике провайдер определяет resources и data sources. Ресурс участвует в полном жизненном цикле: Terraform может создать, прочитать, изменить и удалить удалённый объект. Data source играет другую роль: это read-only тип, который только читает внешние данные и записывает их в state, не меняя удалённую систему.

Ниже — минимальный путь разработки провайдера на Go с использованием Terraform Plugin SDKv2: от main.go и schema.Provider до простого ресурса, схемы, CRUD-функций и локального запуска без публикации в Registry настолько, насколько это прямо поддержано документированным режимом разработки.

Что именно добавляет собственный провайдер

Полезно сразу провести прикладную границу. Провайдер — это плагин Terraform, который объявляет, какие типы объектов доступны пользователю. В SDKv2 это обычно два набора:

  • resources — управляемые объекты с create, read, update, delete;
  • data sources — read-only объекты, которые только читают внешние данные.

Это различие влияет и на код. Если вы делаете ресурс, то описываете полный lifecycle. Если делаете data source, то реализуете только чтение. Поэтому даже в минимальном учебном провайдере полезно показать оба типа: так проще увидеть, где Terraform управляет удалённым объектом, а где только получает сведения о нём.

С чего начинается SDKv2-провайдер

В SDKv2 точка входа обычно находится в main.go. Её задача — запустить provider server, через который Terraform будет общаться с вашим кодом по RPC. В минимальном варианте это выглядит так:

package main

import (
  "github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
  "example.com/terraform-provider-demo/demo"
)

func main() {
  plugin.Serve(&plugin.ServeOpts{
    ProviderFunc: demo.Provider,
    ProviderAddr: "registry.terraform.io/example/demo",
  })
}

Здесь важны две вещи:

  • plugin.Serve поднимает сервер провайдера.
  • ProviderFunc указывает на функцию, которая возвращает *schema.Provider.

Адрес в ProviderAddr — это не декоративная строка. Terraform использует его как идентификатор провайдера. Поэтому адрес в бинарнике, в конфигурации Terraform и в настройках локальной разработки должен совпадать по смыслу.

Что возвращает Provider()

В SDKv2 поведение провайдера описывается структурой schema.Provider. В ней можно объявить:

  • схему самого провайдера, например URL API или токен;
  • функцию конфигурации провайдера;
  • ResourcesMap — набор ресурсов;
  • DataSourcesMap — набор data sources.

Минимальный каркас выглядит так:

package demo

import (
  "context"

  "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

type client struct{}

func Provider() *schema.Provider {
  return &schema.Provider{
    Schema: map[string]*schema.Schema{
      "endpoint": {
        Type:     schema.TypeString,
        Optional: true,
      },
    },
    ConfigureContextFunc: configureProvider,
    ResourcesMap: map[string]*schema.Resource{
      "demo_item": resourceItem(),
    },
    DataSourcesMap: map[string]*schema.Resource{
      "demo_item": dataSourceItem(),
    },
  }
}

func configureProvider(ctx context.Context, d *schema.ResourceData) (any, diag.Diagnostics) {
  c := &client{}
  return c, nil
}

Даже в таком каркасе уже видно разделение ролей. Провайдер сначала настраивает общее окружение работы, а потом публикует список поддерживаемых типов. В meta, который потом попадёт в CRUD-функции, обычно передают подготовленный клиент API.

Как подключить ресурс и data source

В SDKv2 и ресурсы, и data sources представлены типом schema.Resource. Разница не в самой структуре, а в наборе lifecycle-функций.

  • Для ресурса обычно задают CreateContext, ReadContext, UpdateContext, DeleteContext.
  • Для data source достаточно ReadContext.

Минимальный data source может выглядеть так:

func dataSourceItem() *schema.Resource {
  return &schema.Resource{
    ReadContext: dataSourceItemRead,
    Schema: map[string]*schema.Schema{
      "name": {
        Type:     schema.TypeString,
        Required: true,
      },
      "value": {
        Type:     schema.TypeString,
        Computed: true,
      },
    },
  }
}

Такой источник данных получает входной параметр name, делает запрос во внешнюю систему и возвращает вычисляемое значение value. Важная мысль: data source не должен создавать, обновлять или удалять объекты. Это не «облегчённый ресурс», а именно read-only тип.

Минимальный ресурс: схема, атрибуты и state

Ресурс в SDKv2 тоже строится вокруг schema.Resource, но уже с полным CRUD. Самая важная часть — схема, то есть карта map[string]*schema.Schema. В ней описываются атрибуты и их поведение.

Для минимального примера удобно взять три поля:

  • name — обязательное поле пользователя;
  • value — необязательное поле пользователя;
  • created_at — вычисляемое поле, которое приходит только из удалённой системы.
func resourceItem() *schema.Resource {
  return &schema.Resource{
    CreateContext: resourceItemCreate,
    ReadContext:   resourceItemRead,
    UpdateContext: resourceItemUpdate,
    DeleteContext: resourceItemDelete,
    Schema: map[string]*schema.Schema{
      "name": {
        Type:     schema.TypeString,
        Required: true,
      },
      "value": {
        Type:     schema.TypeString,
        Optional: true,
      },
      "created_at": {
        Type:     schema.TypeString,
        Computed: true,
      },
    },
  }
}

У этих признаков разный смысл:

Признак Что означает
Required Пользователь обязан указать значение в конфигурации.
Optional Поле можно указать, но можно и пропустить.
Computed Значение вычисляет провайдер или удалённая система; пользователь его не задаёт напрямую.

Типы тоже задаются явно: TypeString, TypeBool, TypeInt, TypeFloat, а для коллекций — TypeMap, TypeList, TypeSet. Для первого ресурса лучше начать с простых типов и уже потом добавлять агрегаты: их запись в state требует более аккуратной обработки ошибок.

Работа со state идёт через schema.ResourceData. Оттуда читают входные значения через Get(), туда записывают актуальные данные через Set(), а идентификатор ресурса задают через SetId(). Идентификатор — это связь между записью в Terraform state и реальным удалённым объектом.

CRUD в SDKv2: как связать Terraform и удалённый объект

В lifecycle-функциях Terraform передаёт два основных объекта: *schema.ResourceData и meta any. Обычно в meta лежит клиент API, подготовленный в ConfigureContextFunc.

func resourceItemCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
  c := meta.(*client)

  name := d.Get("name").(string)
  value := d.Get("value").(string)

  id := c.CreateItem(name, value)
  d.SetId(id)

  return resourceItemRead(ctx, d, meta)
}

func resourceItemRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
  c := meta.(*client)

  item, err := c.GetItem(d.Id())
  if err != nil {
    return diag.FromErr(err)
  }

  d.Set("name", item.Name)
  d.Set("value", item.Value)
  d.Set("created_at", item.CreatedAt)
  return nil
}

func resourceItemUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
  c := meta.(*client)

  c.UpdateItem(d.Id(), d.Get("value").(string))
  return resourceItemRead(ctx, d, meta)
}

func resourceItemDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
  c := meta.(*client)

  c.DeleteItem(d.Id())
  d.SetId("")
  return nil
}

Даже в таком упрощённом примере видно несколько правил:

  • CreateContext создаёт удалённый объект и сохраняет его ID.
  • ReadContext синхронизирует Terraform state с удалённой системой.
  • UpdateContext меняет объект и затем снова читает его актуальное состояние.
  • DeleteContext удаляет объект и очищает ID.

Функция чтения здесь центральная. Именно Read должен привести state в соответствие с тем, что реально хранится во внешней системе. Если схема объявляет атрибут, его стоит поддерживать в актуальном состоянии, даже если пользователь не задавал его в конфигурации: API может назначить значение по умолчанию или вернуть нормализованную форму данных.

Если вы используете агрегатные типы вроде TypeMap, TypeList или TypeSet, результат Set() лучше проверять на ошибку. Для примитивов это менее заметно, а для агрегатов ошибка записи в state уже может повлиять на последующие plan/apply.

Типичная проблема минимального провайдера: устаревший state после create/update

Это одна из самых полезных привычек для провайдера с первого дня: после создания или обновления ресурса сразу вызывать Read.

Почему это нужно:

  • API может подставить серверные значения по умолчанию;
  • API может изменить регистр, формат или структуру переданных данных;
  • локальный state Terraform сразу после Create или Update может не совпадать с реальным удалённым объектом.

Если не сделать немедленную синхронизацию, следующий terraform plan покажет неожиданные расхождения, хотя пользователь ничего не менял. Поэтому в минимальном примере разумно делать так же, как в коде выше: CreateContext и UpdateContext завершаются вызовом resourceItemRead(...).

Учебный пример. Допустим, вы отправили value = "Admin", а API всегда сохраняет это как admin. Если после Create вы не перечитали объект, в state останется старое значение. Позже Terraform обновит состояние, увидит разницу и попытается её «исправить», хотя на самом деле это штатная нормализация на стороне API.

Локальная разработка: что реально делает dev_overrides

Для разработки Terraform поддерживает dev_overrides в CLI-конфигурации. Это специальный режим, при котором для выбранного адреса провайдера команды вроде plan и apply берут локальную директорию с вашим бинарником и игнорируют обычную проверку версии и checksum для этого провайдера.

Минимальный файл CLI-конфигурации может выглядеть так:

provider_installation {
  dev_overrides {
    "example/demo" = "/home/developer/tmp/terraform-demo"
  }
  direct {}
}

Здесь путь указывает не на исходники, а на директорию, где лежит исполняемый файл провайдера. В этой директории должен быть бинарник с префиксом вида terraform-provider-<type>. Для типа demo это будет имя наподобие terraform-provider-demo.

Часто такой файл конфигурации делают отдельным, например dev.tfrc, и подключают только в текущей сессии через переменную окружения TF_CLI_CONFIG_FILE. Это удобно: разработческая настройка не смешивается с обычной пользовательской конфигурацией Terraform.

export TF_CLI_CONFIG_FILE=/home/developer/tmp/dev.tfrc

Здесь есть важная оговорка, которую нельзя пропускать: dev_overrides не превращает terraform init в полностью автономный режим для произвольного непубликованного провайдера. По документации Terraform, даже при включённом dev_overrides команда terraform init всё равно пытается выбрать подходящую опубликованную версию провайдера и записать её в dependency lock file. А вот другие команды, например terraform apply, затем используют локальную директорию из override.

Из этого следуют два практических вывода:

  • dev_overrides — это именно режим разработки провайдера, а не полная замена механизма публикации;
  • для совершенно непубликованного адреса провайдера нельзя без оговорок обещать обычный путь «init, потом всё работает локально» только за счёт dev_overrides.

Поэтому корректнее формулировать локальную проверку так: вы собираете бинарник, включаете dev_overrides, подключаете локальный .tfrc через TF_CLI_CONFIG_FILE и используете этот режим для разработки и проверки поведения провайдера в командах Terraform, понимая, что этап init по документации всё ещё завязан на выбор опубликованной версии.

Учебный сценарий. Если адрес провайдера уже существует как опубликованный, но вы разрабатываете свою локальную сборку, dev_overrides позволяет подменить поведение на локальный бинарник для дальнейших команд. Если же адрес полностью новый и никогда не публиковался, источник про dev_overrides не даёт оснований обещать беспроблемный сценарий инициализации только этим механизмом.

Как проверять минимальный lifecycle

После сборки бинарника имеет смысл проверять не «всё Terraform сразу», а несколько узких вещей:

  • Terraform видит ваш адрес провайдера и запускает локальный бинарник в режиме разработки.
  • Create действительно назначает идентификатор через SetId().
  • Read возвращает все атрибуты из схемы, которые должны попасть в state.
  • Update не оставляет устаревшее состояние и завершает работу синхронизацией через Read.
  • Delete очищает ID и завершает lifecycle ресурса.

Для минимального прототипа именно эта проверка важнее, чем большой набор возможностей. Если провайдер умеет стабильно пройти цикл создания, чтения, обновления и удаления с корректной синхронизацией state, у вас уже есть прочная база для расширения схемы и добавления новых типов ресурсов.

Где тут место курса

В программе курса по DevOps для действующих IT-специалистов заявлен фрагмент про основы Go и написание собственных расширений для Terraform. Это напрямую совпадает с практикой из статьи: чтобы дойти до рабочего провайдера, нужно понимать устройство schema.Provider, схем ресурсов, lifecycle-функций и цикл локальной разработки.

Что стоит запомнить перед первым прототипом

  • Провайдер добавляет в Terraform новые resources и data sources.
  • Data source — read-only сущность: читает данные, но не создаёт и не удаляет объекты.
  • В SDKv2 точка входа строится вокруг plugin.Serve и функции Provider(), возвращающей *schema.Provider.
  • ResourcesMap и DataSourcesMap подключают конкретные типы ресурсов и источников данных.
  • Схема ресурса описывает типы и поведение атрибутов через Type*, Required, Optional, Computed.
  • Основная работа со state идёт через Get(), Set(), SetId() и функцию Read.
  • После Create и Update лучше сразу вызывать Read, чтобы не оставлять устаревший state.
  • dev_overrides полезен для разработки, но не отменяет документированную особенность: terraform init всё ещё пытается выбрать опубликованную версию провайдера.

Если собрать всё это вместе, получится не просто заготовка, а понятный минимальный провайдер: Terraform сможет запустить бинарник, вызвать CRUD-функции, сохранить ID ресурса и синхронизировать state с удалённой системой. Главное — не переоценивать режим dev_overrides: он удобен для разработки, но его нужно использовать с точной оговоркой о поведении init.