| name | iac-terraform-provider-dev |
| description | Develop custom Terraform and OpenTofu providers using the Plugin Framework. Use when creating new providers, implementing CRUD operations, writing acceptance tests, debugging provider issues, or migrating from SDKv2 to Plugin Framework. Covers TDD workflow, resource/data source patterns, and terraform-plugin-testing. |
Terraform Provider Development
Build production-quality Terraform and OpenTofu providers using the Plugin Framework. This skill covers the complete provider development lifecycle from design through testing and release.
Purpose
Guide the development of custom Terraform providers following HashiCorp's best practices, with emphasis on Test-Driven Development (TDD), proper resource lifecycle management, and comprehensive acceptance testing.
When to Use
- Create new Terraform/OpenTofu providers for APIs or services
- Implement resources, data sources, and functions in providers
- Write acceptance tests using terraform-plugin-testing
- Debug provider behavior and state management issues
- Migrate existing SDKv2 providers to Plugin Framework
- Implement import functionality for resources
- Handle sensitive data and computed attributes properly
TDD Workflow
Follow the RED → GREEN → REFACTOR cycle for all provider development:
┌─────────────────────────────────────────────────────────────┐
│ RED: Write a failing test that defines expected behavior │
│ ↓ │
│ GREEN: Write minimal code to make the test pass │
│ ↓ │
│ REFACTOR: Improve code while keeping tests green │
│ ↓ │
│ REPEAT: Move to next requirement │
└─────────────────────────────────────────────────────────────┘
Never skip the RED phase. A test that never failed provides no confidence.
Provider Structure
terraform-provider-{name}/
├── main.go # Provider entry point
├── go.mod # Go module definition
├── internal/
│ └── provider/
│ ├── provider.go # Provider implementation
│ ├── provider_test.go # Provider tests
│ ├── {resource}_resource.go
│ ├── {resource}_resource_test.go
│ ├── {datasource}_data_source.go
│ └── {datasource}_data_source_test.go
├── examples/
│ ├── provider/
│ │ └── provider.tf
│ ├── resources/
│ │ └── {name}_{resource}/
│ │ └── resource.tf
│ └── data-sources/
│ └── {name}_{datasource}/
│ └── data-source.tf
├── docs/ # Generated documentation
├── templates/ # Doc templates (optional)
└── .goreleaser.yml # Release configuration
Provider Implementation
Basic Provider
package provider
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"
)
var _ provider.Provider = &ExampleProvider{}
type ExampleProvider struct {
version string
}
type ExampleProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
APIKey types.String `tfsdk:"api_key"`
}
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &ExampleProvider{
version: version,
}
}
}
func (p *ExampleProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "example"
resp.Version = p.version
}
func (p *ExampleProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
Optional: true,
Description: "API endpoint URL",
},
"api_key": schema.StringAttribute{
Optional: ,
Sensitive: ,
Description: ,
},
},
}
}
Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
config ExampleProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
resp.Diagnostics.HasError() {
}
client := NewAPIClient(config.Endpoint.ValueString(), config.APIKey.ValueString())
resp.DataSourceData = client
resp.ResourceData = client
}
Resources(ctx context.Context) [] resource.Resource {
[] resource.Resource{
NewThingResource,
}
}
DataSources(ctx context.Context) [] datasource.DataSource {
[] datasource.DataSource{
NewThingDataSource,
}
}
Resource Implementation
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &ThingResource{}
_ resource.ResourceWithImportState = &ThingResource{}
)
type ThingResource struct {
client *APIClient
}
type ThingResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
Status types.String `tfsdk:"status"`
}
func NewThingResource() resource.Resource {
return &ThingResource{}
}
func (r *ThingResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_thing"
}
func (r *ThingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manages a Thing resource.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: ,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
: schema.StringAttribute{
Required: ,
Description: ,
},
: schema.StringAttribute{
Optional: ,
Description: ,
},
: schema.StringAttribute{
Computed: ,
Description: ,
},
},
}
}
Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
req.ProviderData == {
}
client, ok := req.ProviderData.(*APIClient)
!ok {
resp.Diagnostics.AddError(
,
fmt.Sprintf(, req.ProviderData),
)
}
r.client = client
}
Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
plan ThingResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.HasError() {
}
thing, err := r.client.CreateThing(ctx, plan.Name.ValueString(), plan.Description.ValueString())
err != {
resp.Diagnostics.AddError(, err.Error())
}
plan.ID = types.StringValue(thing.ID)
plan.Status = types.StringValue(thing.Status)
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
state ThingResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.HasError() {
}
thing, err := r.client.GetThing(ctx, state.ID.ValueString())
err != {
resp.Diagnostics.AddError(, err.Error())
}
thing == {
resp.State.RemoveResource(ctx)
}
state.Name = types.StringValue(thing.Name)
state.Description = types.StringValue(thing.Description)
state.Status = types.StringValue(thing.Status)
resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
}
Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
plan ThingResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.HasError() {
}
state ThingResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.HasError() {
}
thing, err := r.client.UpdateThing(ctx, state.ID.ValueString(), plan.Name.ValueString(), plan.Description.ValueString())
err != {
resp.Diagnostics.AddError(, err.Error())
}
plan.ID = state.ID
plan.Status = types.StringValue(thing.Status)
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
state ThingResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.HasError() {
}
err := r.client.DeleteThing(ctx, state.ID.ValueString())
err != {
resp.Diagnostics.AddError(, err.Error())
}
}
ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root(), req, resp)
}
Acceptance Testing
Test Setup
package provider
import (
"os"
"testing"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"example": providerserver.NewProtocol6WithError(New("test")()),
}
func testAccPreCheck(t *testing.T) {
if v := os.Getenv("EXAMPLE_API_KEY"); v == "" {
t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
}
}
Resource Test with State Checks
func TestAccThingResource_basic(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccThingResourceConfig("test-thing"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_thing.test", "name", "test-thing"),
resource.TestCheckResourceAttrSet("example_thing.test", "id"),
resource.TestCheckResourceAttrSet("example_thing.test", "status"),
),
},
{
ResourceName: "example_thing.test",
ImportState: true,
ImportStateVerify: true,
},
{
Config: testAccThingResourceConfig("updated-thing"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_thing.test", "name", "updated-thing"),
),
},
},
})
}
func testAccThingResourceConfig(name string) string {
return fmt.Sprintf(`
resource "example_thing" "test" {
name = %[1]q
}
`, name)
}
Plan Checks (terraform-plugin-testing v1.13.3+)
import (
"github.com/hashicorp/terraform-plugin-testing/plancheck"
)
func TestAccThingResource_planChecks(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccThingResourceConfig("test"),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("example_thing.test", plancheck.ResourceActionCreate),
},
},
},
{
Config: testAccThingResourceConfig("updated"),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("example_thing.test", plancheck.ResourceActionUpdate),
},
},
},
},
})
}
State Checks (Modern Assertion Framework)
import (
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/compare"
)
func TestAccThingResource_stateChecks(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccThingResourceConfig("test"),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(
"example_thing.test",
tfjsonpath.New("name"),
knownvalue.StringExact("test"),
),
statecheck.ExpectKnownValue(
"example_thing.test",
tfjsonpath.New("id"),
knownvalue.NotNull(),
),
},
},
},
})
}
Drift Detection
func TestAccThingResource_drift(t *testing.T) {
var thingID string
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccThingResourceConfig("original"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrWith("example_thing.test", "id", func(value string) error {
thingID = value
return nil
}),
),
},
{
PreConfig: func() {
client := getTestClient()
client.UpdateThing(context.Background(), thingID, "drifted", "")
},
Config: testAccThingResourceConfig("original"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_thing.test", "name", "original"),
),
},
},
})
}
Best Practices
1. Schema Design
schema.StringAttribute{
Required: true,
Optional: true,
Computed: true,
Sensitive: true,
Description: "Clear description",
}
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
}
Validators: []validator.String{
stringvalidator.LengthBetween(1, 64),
stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z]`), "must start with lowercase"),
}
2. Error Handling
if err != nil {
resp.Diagnostics.AddError(
"Error Creating Resource",
fmt.Sprintf("Could not create thing: %s", err.Error()),
)
return
}
resp.Diagnostics.AddWarning(
"Deprecation Notice",
"This attribute will be removed in the next major version.",
)
3. Null vs Unknown Handling
if !plan.Description.IsNull() {
description = plan.Description.ValueString()
}
if plan.Status.IsUnknown() {
}
4. Import Testing
{
ResourceName: "example_thing.test",
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"password"},
}
Running Tests
TF_ACC=1 go test -v ./internal/provider/
TF_ACC=1 go test -v ./internal/provider/ -run TestAccThingResource_basic
TF_ACC=1 go test -v -timeout 120m ./internal/provider/
TF_ACC=1 go test -v -parallel 4 ./internal/provider/
Documentation Generation
Use tfplugindocs to generate documentation:
go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest
tfplugindocs generate
tfplugindocs validate
Reference Files
Development Patterns
references/plugin-framework.md - Plugin Framework patterns and migration from SDKv2
references/testing-patterns.md - Comprehensive testing strategies and examples
references/schema-design.md - Schema design patterns and validators
CI/CD
references/release-workflow.md - GoReleaser and GitHub Actions for provider releases
Related Skills
iac-terraform-modules-eng - For Terraform module development
iac-terraform-orchestration-ops - For orchestration with Terragrunt, Terramate, Atmos