linuxlab.io
Учебники▾
  • Линукс и сети
    Файловая система, процессы, TCP/IP, BGP и OSPF
    →
  • Terraform и IaC
    HCL, state, plan/apply на sandbox LocalStack
    →
  • Git и GitHub
    Объектная модель, plumbing, ветвление, GitHub Actions
    →
Все учебники →
ЦеныО платформеВойтиСоздать аккаунт
/
Intro
Lessons
Footer
linuxlab-УчебникиЦеныО платформеКонфиденциальность и куки
Copyright © 2026 LinuxLab. Все права защищены.
linuxlab.io
Учебники▾
  • Линукс и сети
    Файловая система, процессы, TCP/IP, BGP и OSPF
    →
  • Terraform и IaC
    HCL, state, plan/apply на sandbox LocalStack
    →
  • Git и GitHub
    Объектная модель, plumbing, ветвление, GitHub Actions
    →
Все учебники →
ЦеныО платформеВойтиСоздать аккаунт
/
  • Введение
  • Уроки
  • How it works
  • База знаний
  • Шпаргалка
  • Capstone
  • Собеседование
home/terraform/lessons/tf-advanced-03-custom-provider

lesson ── terraform-advanced ── ~22 мин ── 6 шагов

Свой Terraform-provider, Go и Plugin Framework

Минимальный свой provider на Go: ресурс localfile_marker (создаёт файл на disk'е runner'а, ведёт его в state). Это игрушка, но модель та же, что у production-провайдеров, Schema, CRUD-методы, dev_overrides для локального тестирования.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Скаффолд Go-провайдера

    bash
    cd /home/student/tf-provider-demo
    go mod init terraform-provider-localfile
    go get github.com/hashicorp/terraform-plugin-framework@latest
    go get github.com/hashicorp/terraform-plugin-framework/types@latest

    Это подтянет Plugin Framework. Дальше, main.go и сам провайдер.

    ✓ Go-модуль инициализирован.

  2. 02

    main.go и provider.go

    bash
    cat > main.go <<'EOF'
    package main
    import (
        "context"
        "log"
        "github.com/hashicorp/terraform-plugin-framework/providerserver"
    )
    func main() {
        err := providerserver.Serve(context.Background(),
            New("dev"),
            providerserver.ServeOpts{
                Address: "registry.terraform.io/example/localfile",
            },
        )
        if err != nil {
            log.Fatal(err.Error())
        }
    }
    EOF
    cat > provider.go <<'EOF'
    package main
    import (
        "context"
        "github.com/hashicorp/terraform-plugin-framework/datasource"
        "github.com/hashicorp/terraform-plugin-framework/provider"
        "github.com/hashicorp/terraform-plugin-framework/provider/schema"
        "github.com/hashicorp/terraform-plugin-framework/resource"
    )
    type LocalFileProvider struct {
        version string
    }
    func New(version string) func() provider.Provider {
        return func() provider.Provider {
            return &LocalFileProvider{version: version}
        }
    }
    func (p *LocalFileProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
        resp.TypeName = "localfile"
        resp.Version = p.version
    }
    func (p *LocalFileProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
        resp.Schema = schema.Schema{}
    }
    func (p *LocalFileProvider) Configure(_ context.Context, _ provider.ConfigureRequest, _ *provider.ConfigureResponse) {
    }
    func (p *LocalFileProvider) Resources(_ context.Context) []func() resource.Resource {
        return []func() resource.Resource{
            NewMarkerResource,
        }
    }
    func (p *LocalFileProvider) DataSources(_ context.Context) []func() datasource.DataSource {
        return nil
    }
    EOF

    Провайдер пустой по schema (без api_token), один ресурс, Marker.

    ✓ Provider-скелет готов. Теперь, Resource.

  3. 03

    Ресурс localfile_marker

    bash
    cat > marker.go <<'EOF'
    package main
    import (
        "context"
        "fmt"
        "os"
        "github.com/hashicorp/terraform-plugin-framework/resource"
        "github.com/hashicorp/terraform-plugin-framework/resource/schema"
        "github.com/hashicorp/terraform-plugin-framework/types"
    )
    type MarkerResource struct{}
    func NewMarkerResource() resource.Resource {
        return &MarkerResource{}
    }
    type MarkerModel struct {
        Path    types.String `tfsdk:"path"`
        Content types.String `tfsdk:"content"`
        ID      types.String `tfsdk:"id"`
    }
    func (r *MarkerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
        resp.TypeName = req.ProviderTypeName + "_marker"
    }
    func (r *MarkerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
        resp.Schema = schema.Schema{
            Attributes: map[string]schema.Attribute{
                "path": schema.StringAttribute{
                    Required:    true,
                    Description: "Filesystem path to create",
                },
                "content": schema.StringAttribute{
                    Required:    true,
                    Description: "File content",
                },
                "id": schema.StringAttribute{
                    Computed: true,
                },
            },
        }
    }
    func (r *MarkerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
        var plan MarkerModel
        resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
        if resp.Diagnostics.HasError() {
            return
        }
        if err := os.WriteFile(plan.Path.ValueString(), []byte(plan.Content.ValueString()), 0644); err != nil {
            resp.Diagnostics.AddError("write file", err.Error())
            return
        }
        plan.ID = types.StringValue(fmt.Sprintf("marker:%s", plan.Path.ValueString()))
        resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
    }
    func (r *MarkerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
        var state MarkerModel
        resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
        if resp.Diagnostics.HasError() {
            return
        }
        data, err := os.ReadFile(state.Path.ValueString())
        if err != nil {
            if os.IsNotExist(err) {
                resp.State.RemoveResource(ctx)
                return
            }
            resp.Diagnostics.AddError("read file", err.Error())
            return
        }
        state.Content = types.StringValue(string(data))
        resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
    }
    func (r *MarkerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
        var plan MarkerModel
        resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
        if resp.Diagnostics.HasError() {
            return
        }
        if err := os.WriteFile(plan.Path.ValueString(), []byte(plan.Content.ValueString()), 0644); err != nil {
            resp.Diagnostics.AddError("write file", err.Error())
            return
        }
        plan.ID = types.StringValue(fmt.Sprintf("marker:%s", plan.Path.ValueString()))
        resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
    }
    func (r *MarkerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
        var state MarkerModel
        resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
        if resp.Diagnostics.HasError() {
            return
        }
        _ = os.Remove(state.Path.ValueString())
    }
    EOF

    CRUD-методы покрыты. Create пишет файл, Read проверяет существование, Update перезаписывает, Delete удаляет.

    ✓ Ресурс с CRUD написан. Сейчас компиляция.

  4. 04

    Билд и dev_overrides

    bash
    go mod tidy 2>&1 | tail -3
    go install . 2>&1 | tail -3
    ls $HOME/go/bin/terraform-provider-localfile

    Binary готов. Теперь скажи Terraform'у использовать его вместо registry-провайдера:

    bash
    cat > $HOME/.terraformrc <<EOF
    provider_installation {
      dev_overrides {
        "example/localfile" = "$HOME/go/bin"
      }
      direct {}
    }
    EOF

    Теперь любой terraform plan/apply с source = "example/localfile" возьмёт твой binary.

    ✓ Provider скомпилирован и override настроен.

  5. 05

    Terraform-config с custom-provider

    bash
    mkdir -p /home/student/tf-provider-demo/example
    cd /home/student/tf-provider-demo/example
    cat > main.tf <<'EOF'
    terraform {
      required_providers {
        localfile = {
          source = "example/localfile"
        }
      }
    }
    provider "localfile" {}
    resource "localfile_marker" "hello" {
      path    = "/tmp/marker.txt"
      content = "hello from custom provider"
    }
    EOF
    terraform plan 2>&1 | tail -15
    terraform apply -auto-approve 2>&1 | tail -10
    cat /tmp/marker.txt

    Файл создан собственным провайдером. State содержит ресурс localfile_marker.hello. Это полная Terraform-CRUD-петля над твоим кодом.

    ✓ Custom provider работает end-to-end.

    То же самое на OpenTofu

    OpenTofu держит CLI и state совместимыми с Terraform по командам этого шага: миграция обычно проходит через mv .terraform .terraform.bak; tofu init -upgrade. Но при первом переходе сделай backup state и прогон на feature-branch - расхождения концентрируются в новых фичах (variables в backend, state-encryption, OCI registry-backed модули). См. tf-opentofu-parity для полной матрицы.

    • → OpenTofu parity
  6. 06

    Update через свой provider

    bash
    sed -i 's|hello from custom provider|updated content|' main.tf
    terraform plan 2>&1 | tail -10
    terraform apply -auto-approve 2>&1 | tail -10
    cat /tmp/marker.txt

    Plan показал ~ content, apply вызвал Update-метод. Файл перезаписан без destroy/create.

    Destroy:

    bash
    terraform destroy -auto-approve 2>&1 | tail -10
    ls /tmp/marker.txt 2>&1 || echo "file gone"

    Полный CRUD-цикл.

    ✓ CRUD-цикл закрыт. Дальше, масштаб state'а.

    Что нужно для production-provider

    Это игрушечный пример. Production-провайдер добавляет:

    1. Tests на acceptance. TF_ACC=1 go test -v ./... с реальным backend'ом.
    2. Goreleaser для binaries. Multi-platform builds.
    3. GPG-подпись релизов. Registry требует.
    4. Docs через tfplugindocs. Сгенерированы из Schema-description.
    5. Schema versioning + state migrations. Когда меняешь схему, старые state'ы должны быть upgradeable.
    6. Importable resources. ImportState метод для terraform import.
    7. Validation в Schema. validators.StringLengthAtLeast(3) и т.д.

    См. tf-provider-development и официальный template-репо terraform-provider-scaffolding-framework.

    • → Custom-provider целиком

Что ты узнал

Plugin Framework: Provider → Resource → Schema + CRUD-методы. go install . → бинарь в ~/go/bin/. ~/.terraformrc с dev_overrides указывает на этот бинарь. terraform plan/apply использует свой провайдер вместо registry-провайдера.

команды

  • go mod init terraform-provider-localfileстандартный Go-init.
  • go install .компиляция в $GOPATH/bin.
  • terraform planиспользует dev_overrides → локальный бинарь.

концепции

  • · Provider Schema, конфиг для пользователей (api_token, endpoint)
  • · Resource Schema, что описывается в HCL (path, content)
  • · CRUD-методы Create/Read/Update/Delete, каждый получает req/resp

← предыдущий

pre-commit hooks для Terraform

следующий →

Outputs: возвращаем значения наружу

Footer
linuxlab-
Copyright © 2026 LinuxLab. Все права защищены.
Учебники
Цены
О платформе
Конфиденциальность и куки