lesson ── terraform-advanced ── ~22 мин ── 6 шагов
A minimal provider of your own, written in Go: a localfile_marker
resource that creates a file on the runner's disk and tracks it in
state. It is a toy, but the model is the same one production
providers use: Schema, CRUD methods, dev_overrides for local testing.
интерактивный 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
This pulls in the Plugin Framework. Next, main.go and the provider itself.
✓ Go module initialized.
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
The provider has an empty schema (no api_token) and one resource, Marker.
✓ Provider skeleton is ready. Now the 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
All four CRUD methods are covered. Create writes the file, Read checks that it still exists, Update rewrites it, Delete removes it.
✓ The resource with CRUD is written. Now the build.
go mod tidy 2>&1 | tail -3
go install . 2>&1 | tail -3
ls $HOME/go/bin/terraform-provider-localfile
The binary is ready. Now tell Terraform to use it instead of a registry provider:
cat > $HOME/.terraformrc <<EOF
provider_installation { dev_overrides {"example/localfile" = "$HOME/go/bin"
}
direct {}}
EOF
From now on, any terraform plan/apply with source = "example/localfile"
picks up your binary.
✓ Provider compiled and the override is set.
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
The file is created by your own provider. State holds the resource
localfile_marker.hello. That is a full Terraform CRUD loop over
your code.
✓ The custom provider works end to end.
OpenTofu keeps its CLI and state compatible with Terraform for the
commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On your first switch, though,
back up the state and do a run on a feature branch, since the
differences cluster in the newer features (variables in the backend,
state encryption, OCI registry-backed modules). See
tf-opentofu-parity for the full matrix.
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
The plan shows ~ content, and apply calls the Update method. The file
is rewritten without a destroy/create.
Destroy:
terraform destroy -auto-approve 2>&1 | tail -10
ls /tmp/marker.txt 2>&1 || echo "file gone"
A full CRUD cycle.
✓ The CRUD cycle is closed. Next, the scale of state.
This is a toy example. A production provider adds:
TF_ACC=1 go test -v ./... against a real
backend.ImportState method for terraform import.validators.StringLengthAtLeast(3) and
so on.See tf-provider-development and the official template repo
terraform-provider-scaffolding-framework.
Plugin Framework: Provider, then Resource, then Schema plus CRUD
methods. go install . puts a binary in ~/go/bin/. A
~/.terraformrc with dev_overrides points at that binary.
terraform plan/apply then uses your provider instead of a
registry provider.
команды
go mod init terraform-provider-localfilestandard Go init.go install .compile into $GOPATH/bin.terraform planuses dev_overrides, so it runs your local binary.концепции