lesson ── terraform-advanced ── ~22 мин ── 6 шагов
Минимальный свой provider на Go: ресурс localfile_marker (создаёт
файл на disk'е runner'а, ведёт его в state). Это игрушка, но
модель та же, что у production-провайдеров, Schema, CRUD-методы,
dev_overrides для локального тестирования.
интерактивный sandbox
Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.
stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя
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-модуль инициализирован.
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.
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 написан. Сейчас компиляция.
go mod tidy 2>&1 | tail -3
go install . 2>&1 | tail -3
ls $HOME/go/bin/terraform-provider-localfile
Binary готов. Теперь скажи Terraform'у использовать его вместо registry-провайдера:
cat > $HOME/.terraformrc <<EOF
provider_installation { dev_overrides {"example/localfile" = "$HOME/go/bin"
}
direct {}}
EOF
Теперь любой terraform plan/apply с source = "example/localfile"
возьмёт твой binary.
✓ Provider скомпилирован и override настроен.
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 держит CLI и state совместимыми с Terraform по командам
этого шага: миграция обычно проходит через mv .terraform .terraform.bak; tofu init -upgrade. Но при первом переходе
сделай backup state и прогон на feature-branch - расхождения
концентрируются в новых фичах (variables в backend,
state-encryption, OCI registry-backed модули). См.
tf-opentofu-parity для полной матрицы.
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:
terraform destroy -auto-approve 2>&1 | tail -10
ls /tmp/marker.txt 2>&1 || echo "file gone"
Полный CRUD-цикл.
✓ CRUD-цикл закрыт. Дальше, масштаб state'а.
Это игрушечный пример. Production-провайдер добавляет:
TF_ACC=1 go test -v ./... с реальным
backend'ом.ImportState метод для terraform import.validators.StringLengthAtLeast(3) и
т.д.См. tf-provider-development и официальный template-репо
terraform-provider-scaffolding-framework.
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 → локальный бинарь.концепции