Provider Development
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Almost everything Terraform manages is managed by a provider: a separate program that knows how to talk to one API and
that Terraform core starts, queries and stops as needed. Reading a provider’s documentation tells you what it can do;
writing one tells you why Terraform behaves the way it does — why a plan can contain "known after apply", why
terraform plan calls the API at all, why a changed argument sometimes updates an object and sometimes replaces it.
This page explains the contract between core and a provider and then builds a small provider in Go, from the first
schema to a signed release on the registry.
The baseline is Terraform 1.17 (published on 2026-10-21) and OpenTofu 1.13, with the Terraform Plugin Framework 1.19.0
(published on 2026-03-10), terraform-plugin-go 0.31.0, terraform-plugin-testing 1.16.0 and
terraform-plugin-log 0.11.0, which are the versions the official scaffolding repository pinned when the page was checked. The Framework
documentation site already labels v1.18.x as its latest documented version, so versions move quickly; check the release
pages before copying a go.mod. The scaffolding go.mod requires Go 1.25.8.
The page follows Providers (how practitioners consume providers: requirements,
lock file, authentication, aliases) and Resources and Data
Sources (the behaviour a provider has to implement). It is closely related to
Module Development and Publishing, which covers the
same publishing process for modules, to
Sensitive Data, Ephemeral Values and Write-only
Arguments, which is the practitioner view of ephemeral resources, to
Provisioners, Actions and Post-Apply
Operations, which is the practitioner view of actions, and to Functions, which
covers calling provider-defined functions. Testing a provider with the Go helpers is distinct from the
terraform test framework described in Testing. A provider’s schema can be
inspected as JSON, as described in Machine-readable
Output and Integrations.
The provider built here is deliberately small and entirely invented: Bookshelf, a provider for the Bookshelf catalog
service’s REST API. It manages books, reads them, hands out short-lived session tokens, normalises ISBNs and can ask the
catalog to rebuild its search index. The Go listings are original, but their structure follows the official
terraform-provider-scaffolding-framework repository and the Framework documentation, and the listings are excerpts rather than a
complete, compiled module: treat them as a worked outline and start from the scaffolding repository
for a project that builds.
How a provider works
A provider is an executable. Terraform core locates it (see the installation steps in Providers), starts it as a child process and talks to it over a local connection. The provider is a gRPC server; core is the client. Nothing in a provider performs planning, dependency analysis or state storage: those belong to core. A provider answers questions.
The plugin protocol
The protocol is defined with Protocol Buffers and gRPC; the canonical definitions live in the Terraform CLI repository. There are two major versions:
| Protocol | What the documentation says |
|---|---|
Version 5 |
Works with Terraform CLI 0.12 and later. Implemented by |
Version 6 |
Works with Terraform CLI 1.0 and later and includes all version 5 functionality. Adds nested attributes (a
|
Major versions mark compatibility boundaries; minor versions are additive. The proto files shipped in the Terraform 1.17
tag declare protocol 5.10 and 6.11, so the "version 6" that providers advertise in the registry manifest as 6.0
means "major version 6, at least the 6.0 feature set". The terraform-plugin-go module exposes the protocol as the
tfprotov5 and tfprotov6 packages and servers tf5server and tf6server; the Framework sits on top of it and
supports both. The helper servers tf5to6server, tf6to5server, tf5muxserver and tf6muxserver translate and
combine providers, which is how an SDKv2 provider is migrated to the Framework one resource at a time (see
SDKv2 and the Framework).
The remote procedure calls
Reading the version 6 proto file shows what core can ask a provider. The calls fall into groups:
| Remote procedure call | Purpose |
|---|---|
|
Return the schema of the provider, every resource, data source, ephemeral resource and action, and the function
signatures. Core calls this first, and everything else is validated against it. |
|
Check a configuration block before planning. They run offline, so they cannot call the API. |
|
Hand the evaluated provider configuration to the provider (endpoint, credentials). The provider builds its API client. |
|
Convert a state written by an older provider version to the current schema. This is what a schema |
|
Refresh: fetch the real object and return its current attributes. Core calls this during |
|
Given prior state, configuration and a proposed new state, return the planned state: computed values stay unknown, and the provider lists attribute paths that force replacement. The Framework’s plan modifiers run here. |
|
Perform the create, update or delete and return the new state. The provider must return exactly what was planned for every known value, or core reports "Provider produced inconsistent result after apply". |
|
Support |
|
Evaluate a data source. |
|
The lifecycle of an ephemeral resource: open it, renew it before it expires, close it when core no longer needs it. |
|
List and call provider-defined functions. |
|
Plan and run an action; |
|
Back the |
|
Support resource identity: a second, immutable schema that identifies a remote object independently of its state. |
|
Generate configuration for |
State store calls ( |
Let a provider store Terraform state itself. The Framework marks its support experimental (see What changed recently). |
|
Ask the provider to cancel in-flight work, for example after an interrupt. |
The call order explains behaviour that surprises newcomers. A single terraform plan makes core start the provider,
call GetProviderSchema, validate every block, call ConfigureProvider, call ReadResource for each resource in the
state, and finally call PlanResourceChange for each. terraform apply calls PlanResourceChange again and then
ApplyResourceChange. The same provider process serves the whole command, and a provider can be started several times
during one run (for example once for validation and once for planning), so a provider must not assume it is long-lived.
The diagram is a simplification of the calls named in the proto file, shown for one resource in one run.
Schemas and the type system
Every block a provider offers is described by a schema: a set of attributes (name, type, and whether it is required,
optional, computed or sensitive) and nested blocks. The schema is the whole interface between a configuration and the
provider code. Core uses it to validate configuration, to decide which arguments are "known after apply", to render
the plan and to mask sensitive values; terraform providers schema -json prints it. The values themselves cross the
wire as an opaque dynamic encoding, and the Framework converts them into Go types for you.
The Framework gives each attribute value a type that can be null, unknown or a concrete value. This three-way distinction is the main thing the older SDK could not express. Null means the value is absent; unknown means Terraform cannot know it yet, usually because it depends on an object that does not exist. The documentation states two guarantees: required attributes are never null or unknown in Create, Read, Update and Delete, and state never contains unknown values.
Choosing the SDK
There are two Go libraries for writing a provider and a lower-level one beneath them:
| Library | Status according to the documentation |
|---|---|
Terraform Plugin Framework ( |
The recommended SDK for new providers and for migrating existing ones when possible. Supports both protocol versions (5 and 6). Separates configuration, plan and state, distinguishes null from unknown, has compile-time interfaces for every component and is the only SDK that supports ephemeral resources, provider-defined functions, actions and list resources. |
Terraform Plugin SDKv2 ( |
A legacy option for maintaining existing providers, maintained for Terraform 1.x, with most feature development stopped. Speaks protocol version 5 only. Its latest documented minor was 2.39.x when the SDKv2 page was fetched and its GitHub release list showed 2.41.0. |
Terraform Plugin Go ( |
The low-level library that implements the protocol ( |
Terraform Plugin Mux ( |
Combines several providers behind one binary, and translates between protocol versions. The documented migration path from SDKv2: run SDKv2 and Framework resources side by side and move them over gradually. |
SDKv2 and the Framework
If you are reading older material, the SDKv2 vocabulary is different: schema.Resource with function fields
(CreateContext, ReadContext), d.Set and d.Get, resource.TestCase imported from terraform-plugin-sdk/v2, and
a website/docs directory of Markdown. None of that applies to a new provider. The Framework replaces the generic
schema.Resource with a Go interface per component, d.Get with typed structs tagged tfsdk:"name", and the SDK’s
test helpers with the separate terraform-plugin-testing module. The scaffolding repository even configures its linter
to forbid importing terraform-plugin-sdk/v2 in a Framework provider.
Providers for OpenTofu use the same libraries: OpenTofu implements the same plugin protocol, so a provider built with the Framework runs under both tools, and the registry ecosystem is covered in Publishing to a registry.
The Bookshelf provider
The sample provider wraps this hypothetical REST API (all names are invented for this page):
| Request | Meaning |
|---|---|
|
Create a book from |
|
Read a book. Answers 404 when it does not exist. |
|
Replace |
|
Delete a book. |
|
Create a short-lived session token with a time to live. |
|
Revoke a session token. |
|
Rebuild the catalog search index. |
The module layout follows the scaffolding repository:
terraform-provider-bookshelf/
|-- main.go # serves the provider
|-- go.mod
|-- GNUmakefile # build, lint, test, testacc, generate
|-- .goreleaser.yml # release build and signing
|-- terraform-registry-manifest.json # protocol version metadata for the registry
|-- internal/
| |-- bookshelf/client.go # a tiny HTTP client for the REST API
| `-- provider/
| |-- provider.go # the provider
| |-- book_resource.go # bookshelf_book
| |-- book_data_source.go # data.bookshelf_book
| |-- session_ephemeral_resource.go# ephemeral bookshelf_session
| |-- isbn_function.go # provider::bookshelf::isbn13
| |-- reindex_action.go # action bookshelf_reindex
| `-- *_test.go
|-- examples/ # source of the generated documentation examples
|-- templates/ # optional tfplugindocs templates
|-- docs/ # generated by tfplugindocs
`-- tools/tools.go # go:generate directives
The repository must be named terraform-provider-NAME to be published on the public registry, so this one is
terraform-provider-bookshelf. The module path in go.mod is a placeholder
(github.com/example/terraform-provider-bookshelf).
The API client
A provider is mostly a thin translation layer, so the client is kept out of the Framework types. This abbreviated version shows the shape the resource code relies on: it returns a sentinel error for 404 and exposes the ETag.
package bookshelf
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
)
// ErrNotFound is returned when the API answers 404.
var ErrNotFound = errors.New("not found")
// Book is the API representation of a book.
type Book struct {
ID string `json:"id,omitempty"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Shelf string `json:"shelf"`
}
// Client talks to the Bookshelf REST API.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
}
func (c *Client) do(ctx context.Context, method, path, etag string, in, out any) (string, error) {
var body bytes.Buffer
if in != nil {
if err := json.NewEncoder(&body).Encode(in); err != nil {
return "", err
}
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, &body)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
if etag != "" {
req.Header.Set("If-Match", etag)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", ErrNotFound
}
if resp.StatusCode >= 300 {
return "", fmt.Errorf("bookshelf API returned %s", resp.Status)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return "", err
}
}
return resp.Header.Get("ETag"), nil
}
// CreateBook creates a book and returns it together with its ETag.
func (c *Client) CreateBook(ctx context.Context, b Book) (Book, string, error) {
var out Book
etag, err := c.do(ctx, http.MethodPost, "/v1/books", "", b, &out)
return out, etag, err
}
// GetBook reads a book and returns it together with its ETag.
func (c *Client) GetBook(ctx context.Context, id string) (Book, string, error) {
var out Book
etag, err := c.do(ctx, http.MethodGet, "/v1/books/"+id, "", nil, &out)
return out, etag, err
}
// UpdateBook replaces a book; the ETag guards against concurrent changes.
func (c *Client) UpdateBook(ctx context.Context, b Book, etag string) (Book, string, error) {
var out Book
newETag, err := c.do(ctx, http.MethodPut, "/v1/books/"+b.ID, etag, b, &out)
return out, newETag, err
}
// DeleteBook deletes a book.
func (c *Client) DeleteBook(ctx context.Context, id string) error {
_, err := c.do(ctx, http.MethodDelete, "/v1/books/"+id, "", nil, nil)
return err
}
// The session and index calls (CreateSession, DeleteSession, RebuildIndex)
// follow the same pattern and are omitted here.
main.go
main.go starts the provider server. It follows the scaffolding file: a version variable that GoReleaser sets with
a linker flag, a -debug flag for debuggers (see Debugging a provider) and an Address that must be the provider’s
full source address.
package main
import (
"context"
"flag"
"log"
"github.com/example/terraform-provider-bookshelf/internal/provider"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
)
// version is set by GoReleaser through -ldflags.
var version string = "dev"
func main() {
var debug bool
flag.BoolVar(&debug, "debug", false, "run the provider with support for debuggers like delve")
flag.Parse()
opts := providerserver.ServeOpts{
Address: "registry.terraform.io/example/bookshelf",
Debug: debug,
}
if err := providerserver.Serve(context.Background(), provider.New(version), opts); err != nil {
log.Fatal(err.Error())
}
}
providerserver.Serve speaks protocol version 6 by default. To serve protocol 5 instead, use
providerserver.NewProtocol5 with tf5server yourself; in that case the registry manifest must advertise 5.0
(see Publishing to a registry).
The provider
A provider type implements provider.Provider (Metadata, Schema, Configure, DataSources, Resources) and opts
in to the newer components by implementing extra interfaces: ProviderWithFunctions, ProviderWithEphemeralResources
and ProviderWithActions. The scaffolding repository declares them with compile-time assertions, and so does this
code.
package provider
import (
"context"
"net/http"
"os"
"time"
"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
"github.com/hashicorp/terraform-plugin-framework/action"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
"github.com/hashicorp/terraform-plugin-framework/function"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ provider.Provider = &BookshelfProvider{}
_ provider.ProviderWithFunctions = &BookshelfProvider{}
_ provider.ProviderWithEphemeralResources = &BookshelfProvider{}
_ provider.ProviderWithActions = &BookshelfProvider{}
)
// BookshelfProvider is the provider implementation.
type BookshelfProvider struct {
version string
}
// BookshelfProviderModel is the provider configuration.
type BookshelfProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
Token types.String `tfsdk:"token"`
}
func New(version string) func() provider.Provider {
return func() provider.Provider { return &BookshelfProvider{version: version} }
}
func (p *BookshelfProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "bookshelf"
resp.Version = p.version
}
func (p *BookshelfProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Manage the Bookshelf catalog through its REST API.",
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
MarkdownDescription: "Base URL of the API. May also be set with the `BOOKSHELF_ENDPOINT` environment variable.",
Optional: true,
},
"token": schema.StringAttribute{
MarkdownDescription: "API token. May also be set with the `BOOKSHELF_TOKEN` environment variable.",
Optional: true,
Sensitive: true,
},
},
}
}
func (p *BookshelfProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var data BookshelfProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// An unknown value means the configuration depends on something that does
// not exist yet; the client cannot be built during this run.
if data.Endpoint.IsUnknown() {
resp.Diagnostics.AddAttributeError(path.Root("endpoint"),
"Unknown Bookshelf endpoint",
"The endpoint must be known when the provider is configured. Set it to a static value or an environment variable.")
}
if data.Token.IsUnknown() {
resp.Diagnostics.AddAttributeError(path.Root("token"),
"Unknown Bookshelf token",
"The token must be known when the provider is configured. Use the BOOKSHELF_TOKEN environment variable.")
}
if resp.Diagnostics.HasError() {
return
}
endpoint := os.Getenv("BOOKSHELF_ENDPOINT")
token := os.Getenv("BOOKSHELF_TOKEN")
if !data.Endpoint.IsNull() {
endpoint = data.Endpoint.ValueString()
}
if !data.Token.IsNull() {
token = data.Token.ValueString()
}
if endpoint == "" || token == "" {
resp.Diagnostics.AddError("Missing Bookshelf credentials",
"Set the endpoint and token arguments or the BOOKSHELF_ENDPOINT and BOOKSHELF_TOKEN environment variables.")
return
}
client := &bookshelf.Client{BaseURL: endpoint, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}}
// Every component type receives the same client through its own field.
resp.DataSourceData = client
resp.ResourceData = client
resp.EphemeralResourceData = client
resp.ActionData = client
}
func (p *BookshelfProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{NewBookResource}
}
func (p *BookshelfProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{NewBookDataSource}
}
func (p *BookshelfProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource {
return []func() ephemeral.EphemeralResource{NewSessionEphemeralResource}
}
func (p *BookshelfProvider) Functions(_ context.Context) []func() function.Function {
return []func() function.Function{NewISBN13Function}
}
func (p *BookshelfProvider) Actions(_ context.Context) []func() action.Action {
return []func() action.Action{NewReindexAction}
}
Three details of Configure are worth keeping in mind.
-
Environment variables are the provider’s job. Core does not read
BOOKSHELF_TOKEN; the provider does, and the configured value wins. That is the convention that makes theTF_VAR_-free credential patterns of Providers possible. -
Unknown values are real. When a provider argument comes from another resource, it is unknown during planning of a new configuration. The code above fails with a clear message; a provider that tolerates it must defer client creation.
-
Configureruns before every other call that needs the client but also forterraform validate-adjacent flows where provider data may be missing, so each component must checkreq.ProviderData == niland return quietly. The scaffolding code and the Framework action documentation both show that guard.
Managed resources
A resource implements resource.Resource: Metadata, Schema, Create, Read, Update and Delete.
Optional interfaces add behaviour: ResourceWithConfigure (receive the client), ResourceWithImportState,
ResourceWithModifyPlan, ResourceWithConfigValidators, ResourceWithValidateConfig, ResourceWithUpgradeState,
ResourceWithIdentity and others.
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
var (
_ resource.Resource = &BookResource{}
_ resource.ResourceWithConfigure = &BookResource{}
_ resource.ResourceWithImportState = &BookResource{}
_ resource.ResourceWithModifyPlan = &BookResource{}
)
func NewBookResource() resource.Resource { return &BookResource{} }
type BookResource struct {
client *bookshelf.Client
}
type BookResourceModel struct {
ID types.String `tfsdk:"id"`
Title types.String `tfsdk:"title"`
Author types.String `tfsdk:"author"`
ISBN types.String `tfsdk:"isbn"`
Shelf types.String `tfsdk:"shelf"`
}
func (r *BookResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_book"
}
func (r *BookResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "A book in the Bookshelf catalog.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Identifier generated by the API.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"title": schema.StringAttribute{
Required: true,
MarkdownDescription: "Title of the book.",
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"author": schema.StringAttribute{
Required: true,
MarkdownDescription: "Author of the book.",
},
"isbn": schema.StringAttribute{
Required: true,
MarkdownDescription: "ISBN-13 of the book. Changing it replaces the resource.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"shelf": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("unsorted"),
MarkdownDescription: "Shelf the book is placed on.",
},
},
}
}
func (r *BookResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return // the provider has not been configured yet
}
client, ok := req.ProviderData.(*bookshelf.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type",
fmt.Sprintf("Expected *bookshelf.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData))
return
}
r.client = client
}
func (r *BookResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan BookResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
created, etag, err := r.client.CreateBook(ctx, bookshelf.Book{
Title: plan.Title.ValueString(), Author: plan.Author.ValueString(),
ISBN: plan.ISBN.ValueString(), Shelf: plan.Shelf.ValueString(),
})
if err != nil {
resp.Diagnostics.AddError("Unable to create book", err.Error())
return
}
tflog.Trace(ctx, "created a book", map[string]any{"id": created.ID})
plan.ID = types.StringValue(created.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
resp.Diagnostics.Append(setETag(ctx, resp.Private.SetKey, etag)...)
}
func (r *BookResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state BookResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
book, etag, err := r.client.GetBook(ctx, state.ID.ValueString())
if errors.Is(err, bookshelf.ErrNotFound) {
// The object was deleted outside Terraform: drop it from state so that
// the next plan proposes to create it again.
tflog.Warn(ctx, "book no longer exists, removing it from state", map[string]any{"id": state.ID.ValueString()})
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Unable to read book", err.Error())
return
}
state.Title = types.StringValue(book.Title)
state.Author = types.StringValue(book.Author)
state.ISBN = types.StringValue(book.ISBN)
state.Shelf = types.StringValue(book.Shelf)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
resp.Diagnostics.Append(setETag(ctx, resp.Private.SetKey, etag)...)
}
func (r *BookResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan BookResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
// Private state carries the ETag from the last Create or Read.
raw, diags := req.Private.GetKey(ctx, "etag")
resp.Diagnostics.Append(diags...)
var held struct{ Value string }
if raw != nil {
_ = json.Unmarshal(raw, &held)
}
_, etag, err := r.client.UpdateBook(ctx, bookshelf.Book{
ID: plan.ID.ValueString(), Title: plan.Title.ValueString(), Author: plan.Author.ValueString(),
ISBN: plan.ISBN.ValueString(), Shelf: plan.Shelf.ValueString(),
}, held.Value)
if err != nil {
resp.Diagnostics.AddError("Unable to update book", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
resp.Diagnostics.Append(setETag(ctx, resp.Private.SetKey, etag)...)
}
func (r *BookResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state BookResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
err := r.client.DeleteBook(ctx, state.ID.ValueString())
if err != nil && !errors.Is(err, bookshelf.ErrNotFound) {
resp.Diagnostics.AddError("Unable to delete book", err.Error())
}
}
func (r *BookResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
The shape of the code follows the scaffolding example_resource.go: a model struct whose tfsdk tags match the schema,
Get to read the plan or state into it, Set to write state back, RemoveResource when the object is gone, and
ImportStatePassthroughID to make terraform import bookshelf_book.dune <id> work. Four rules come out of it.
-
Create and Update read the plan; Read and Delete read the state. Reading configuration there loses the computed values.
-
Every computed attribute must be set by the end of
Create, and every attribute that was known in the plan must be returned unchanged, or core reports an inconsistent result. -
Readmust tolerate drift: a missing object removes the resource from state instead of failing. -
Deleting something already gone is success.
Schema attributes
The schema attribute fields used above are the ones every provider needs. The Required, Optional, Computed and
Sensitive flags have the practitioner-visible meanings described in
Resources and Data Sources; the rest are Framework features.
| Field or helper | Purpose |
|---|---|
|
Who supplies the value. An attribute that is both |
|
Core hides the value in plan output. It still lives in state in plain text. |
|
A default applied to null configuration values before plan modifiers run. Requires |
|
Checks that run during validation, such as |
|
Attribute-level plan logic (see Plan modification). |
|
Used by the language server and by tfplugindocs to generate documentation. Write one for every attribute. |
|
Marks an attribute deprecated; core warns when it is set. |
|
For managed-resource attributes whose value is sent to the provider but never stored in plan or state. |
Timeouts use a companion module, terraform-plugin-framework-timeouts: add the timeouts.Attributes helper (with timeouts.Opts selecting the
operations) under the key timeouts, a timeouts.Value field to the model, and call
plan.Timeouts.Create(ctx, 20*time.Minute) in Create to get the configured duration, then wrap the context with
context.WithTimeout. The page recommends attributes over nested blocks for new providers.
Source: Terraform Docs — Timeouts
Plan modification
Planning is the point of a provider’s value: this is where "this change replaces the object" is decided. The documented
order of execution for a PlanResourceChange call is:
-
attributes whose configuration value is null get their
Defaultvalue; -
if the plan differs from the prior state, computed attributes that are null in configuration are marked unknown;
-
attribute plan modifiers run;
-
the resource-level
ModifyPlanruns.
The built-in attribute modifiers live in typed packages such as stringplanmodifier:
| Modifier | Effect |
|---|---|
|
A change to the value forces a replacement (destroy then create, or create then destroy with
|
|
The same, but only when provider-defined logic says so. |
|
Replace only when the practitioner configured a value. |
|
Copy the prior state value into the plan if it is not null. It stops stable computed values such as |
For logic across attributes, implement resource.ResourceWithModifyPlan. Plan modifiers run for every operation,
including destroy, so the code must check which one it is: req.State.Raw.IsNull() means create and
req.Plan.Raw.IsNull() means destroy. On destroy the provider may add diagnostics (Terraform 1.3 and later) but must
leave the response plan null.
// ModifyPlan warns before a book is deleted from the catalog.
func (r *BookResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
// A null plan means the resource is being destroyed.
if req.Plan.Raw.IsNull() {
resp.Diagnostics.AddWarning(
"Deleting a book removes its reviews",
"The Bookshelf API deletes every review attached to the book when the book is deleted.",
)
}
}
Two caveats from the documentation are worth stating. First, Terraform enforces consistency between configuration, plan and state, and the Framework raises few targeted errors, so the provider’s own logic has to obey them: a value that will be known only after apply must be marked unknown in the plan, or apply fails with "Provider produced inconsistent result". Second, plan modifiers on list or set attributes that depend on prior state can see misaligned data, because the Framework aligns prior state by array index and does not realign it when elements are reordered or removed.
Private state
Private state is provider-maintained data stored in the Terraform state next to the schema attributes. Terraform
plans never expose it, so practitioners never see it. It is meant for details a provider needs between operations but
that are not part of the interface: the Bookshelf provider stores the ETag of the last known version of a book so that
Update can send If-Match. Other uses are timeouts bookkeeping and remote revisions.
The API is two methods on the Private field of the request or response:
// setETag stores the ETag as JSON under the key "etag".
func setETag(ctx context.Context, set func(context.Context, string, []byte) diag.Diagnostics, etag string) diag.Diagnostics {
value, _ := json.Marshal(struct{ Value string }{Value: etag})
return set(ctx, "etag", value)
}
(The helper needs "github.com/hashicorp/terraform-plugin-framework/diag" in its imports.) GetKey(ctx, key) returns the
bytes, or nil when the key has no data; SetKey(ctx, key, value) stores them, and passing nil or an empty slice
removes the key. The rules from the documentation:
-
values must be valid JSON and safe UTF-8, otherwise
SetKeyreturns an error diagnostic; -
keys that begin with a period are reserved for the Framework;
-
private data can be written in Create, Read, Update, ModifyPlan and Import, and read in Read, Update, ModifyPlan and Delete; the
planmodifierrequest and response types havePrivatefields too.
Private state is not a place for secrets: it is stored in the state file, which is why State and Sensitive Data treat state as sensitive whatever the schema says.
Logging
Providers write logs with the terraform-plugin-log module and its tflog package. Logs are structured: a message
plus a map of fields, which Terraform prints in the format selected by the user. Always pass the context.Context the
Framework gave you, because it carries the logger.
tflog.Debug(ctx, "calling the Bookshelf API", map[string]any{
"method": "POST",
"path": "/v1/books",
})
// SetField returns a new context whose logger includes the field on later calls.
ctx = tflog.SetField(ctx, "book_id", id)
tflog.Trace(ctx, "waiting for the index")
// Mask a secret before anything logs it.
ctx = tflog.MaskFieldValuesWithFieldKeys(ctx, "token")
The levels from the least to the most verbose are Error, Warn, Info, Debug and Trace. The masking and
omission helpers (MaskFieldValuesWithFieldKeys, MaskAllFieldValuesStrings, MaskMessageRegexes,
OmitLogWithFieldKeys and others) exist in the tflog package; the page above documents the basic calls, and the
masking functions were confirmed in the module’s source rather than on a documentation page. Subsystems
(tflog.NewSubsystem) group logs from an area such as the API client so that its verbosity can be set independently,
for example with tflog.WithLevelFromEnv("TF_LOG_PROVIDER_BOOKSHELF_CLIENT"). Do not print with fmt.Println: its
output is not reliably visible.
Practitioners switch logging on with environment variables, described on the log-management page:
| Variable | Effect |
|---|---|
|
Enables logs for the Terraform binary, providers and SDKs. It overrides the more specific variables. Levels are
|
|
Logs from the Terraform binary only. |
|
Logs from providers and their SDKs. Per-provider variables such as |
|
Logs the SDK itself writes with |
|
A file to append logs to. Without it, logs go to standard error. Never write to standard output, because the plugin system uses it. |
Data sources
A data source implements datasource.DataSource (Metadata, Schema, Read) and, to receive the client,
DataSourceWithConfigure. Its schema uses Required for the lookup key and Computed for the result.
package provider
import (
"context"
"fmt"
"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ datasource.DataSource = &BookDataSource{}
_ datasource.DataSourceWithConfigure = &BookDataSource{}
)
func NewBookDataSource() datasource.DataSource { return &BookDataSource{} }
type BookDataSource struct{ client *bookshelf.Client }
type BookDataSourceModel struct {
ID types.String `tfsdk:"id"`
Title types.String `tfsdk:"title"`
Author types.String `tfsdk:"author"`
ISBN types.String `tfsdk:"isbn"`
Shelf types.String `tfsdk:"shelf"`
}
func (d *BookDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_book"
}
func (d *BookDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Reads a book from the Bookshelf catalog by identifier.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Required: true, MarkdownDescription: "Identifier of the book."},
"title": schema.StringAttribute{Computed: true, MarkdownDescription: "Title of the book."},
"author": schema.StringAttribute{Computed: true, MarkdownDescription: "Author of the book."},
"isbn": schema.StringAttribute{Computed: true, MarkdownDescription: "ISBN-13 of the book."},
"shelf": schema.StringAttribute{Computed: true, MarkdownDescription: "Shelf of the book."},
},
}
}
func (d *BookDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*bookshelf.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected data source configure type",
fmt.Sprintf("Expected *bookshelf.Client, got: %T.", req.ProviderData))
return
}
d.client = client
}
func (d *BookDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var data BookDataSourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
book, _, err := d.client.GetBook(ctx, data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to read book", err.Error())
return
}
data.Title = types.StringValue(book.Title)
data.Author = types.StringValue(book.Author)
data.ISBN = types.StringValue(book.ISBN)
data.Shelf = types.StringValue(book.Shelf)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
Unlike a resource, a data source has no plan phase of its own: core calls ReadDataSource when the configuration is
known, which may be during planning or, if an argument is unknown, during apply.
Ephemeral resources
An ephemeral resource produces a value that is never stored in plan or state: a token, a secret read from a vault, a temporary credential. Terraform guarantees that ephemeral resource data is not persisted in plan or state artifacts. They require Terraform 1.10 or later (practitioner view in Sensitive Data, Ephemeral Values and Write-only Arguments). The Framework added them in 1.13.0 as a technical preview and declared them generally available in 1.14.0.
An ephemeral resource implements ephemeral.EphemeralResource, which has Metadata, Schema and Open. Two optional
interfaces cover the rest of the lifecycle: EphemeralResourceWithRenew for refreshing an expired object and
EphemeralResourceWithClose for releasing it. Open reads the configuration, creates or fetches the object, sets the
result with resp.Result.Set, may set resp.RenewAt to say when core must call Renew, and may store data for
Renew and Close in resp.Private. The documented caveats are that the result cannot contain unknown values (set
everything to null or a known value) and that every non-computed configuration value must be saved unchanged.
package provider
import (
"context"
"fmt"
"time"
"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ ephemeral.EphemeralResource = &SessionEphemeralResource{}
_ ephemeral.EphemeralResourceWithConfigure = &SessionEphemeralResource{}
_ ephemeral.EphemeralResourceWithClose = &SessionEphemeralResource{}
)
func NewSessionEphemeralResource() ephemeral.EphemeralResource { return &SessionEphemeralResource{} }
type SessionEphemeralResource struct{ client *bookshelf.Client }
type SessionModel struct {
TTLSeconds types.Int64 `tfsdk:"ttl_seconds"`
Token types.String `tfsdk:"token"`
}
func (e *SessionEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_session"
}
func (e *SessionEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "A short-lived Bookshelf session token that never reaches the Terraform state.",
Attributes: map[string]schema.Attribute{
"ttl_seconds": schema.Int64Attribute{Required: true, MarkdownDescription: "Lifetime of the token in seconds."},
"token": schema.StringAttribute{Computed: true, Sensitive: true, MarkdownDescription: "The session token."},
},
}
}
func (e *SessionEphemeralResource) Configure(_ context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*bookshelf.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected ephemeral resource configure type",
fmt.Sprintf("Expected *bookshelf.Client, got: %T.", req.ProviderData))
return
}
e.client = client
}
func (e *SessionEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
var data SessionModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
ttl := time.Duration(data.TTLSeconds.ValueInt64()) * time.Second
sessionID, token, err := e.client.CreateSession(ctx, ttl) // omitted client method
if err != nil {
resp.Diagnostics.AddError("Unable to open session", err.Error())
return
}
data.Token = types.StringValue(token)
resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...)
// Remember what Close needs. Ask core to call Renew a minute early if the
// resource also implements EphemeralResourceWithRenew.
resp.Diagnostics.Append(resp.Private.SetKey(ctx, "session_id", []byte(`"`+sessionID+`"`))...)
resp.RenewAt = time.Now().Add(ttl - time.Minute)
}
func (e *SessionEphemeralResource) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) {
raw, diags := req.Private.GetKey(ctx, "session_id")
resp.Diagnostics.Append(diags...)
if raw == nil {
return
}
if err := e.client.DeleteSession(ctx, string(raw[1:len(raw)-1])); err != nil { // omitted client method
resp.Diagnostics.AddError("Unable to close session", err.Error())
}
}
The Renew and Close request and response types carry the Private data that Open stored, as the Framework source
documents. In configuration, practitioners use the resource in an ephemeral block and may pass its values only to
ephemeral contexts and write-only arguments, which is why the token is declared Sensitive as well as ephemeral.
terraform {
required_version = ">= 1.14"
required_providers {
bookshelf = {
source = "example/bookshelf"
version = "~> 0.1"
}
}
}
provider "bookshelf" {
endpoint = "https://bookshelf.example.com" # or BOOKSHELF_ENDPOINT
# The token comes from the BOOKSHELF_TOKEN environment variable.
}
ephemeral "bookshelf_session" "import" {
ttl_seconds = 600
}
resource "bookshelf_book" "dune" {
title = "Dune"
author = "Frank Herbert"
isbn = provider::bookshelf::isbn13("0-441-17271-7")
shelf = "science-fiction"
}
Provider-defined functions
A provider can expose functions that practitioners call as provider::<name>::<function>(...). They need Terraform 1.8
or later. The Framework added support as a technical preview in 1.5.0 and declared it generally available in 1.8.0.
A function implements function.Function, which has three methods: Metadata (the name, without the provider
prefix), Definition (summary, ordered parameters, an optional variadic parameter, the return type and an optional
deprecation message) and Run (read arguments, compute the result, set it). Errors are returned through resp.Error
as function errors, combined with function.ConcatFuncErrors; for variadic arguments function.NewArgumentFuncError
points at the right argument. By default Terraform does not pass null or unknown values to a function: the parameter
must set AllowNullValue or AllowUnknownValues to receive them.
package provider
import (
"context"
"strings"
"github.com/hashicorp/terraform-plugin-framework/function"
)
var _ function.Function = &ISBN13Function{}
func NewISBN13Function() function.Function { return &ISBN13Function{} }
type ISBN13Function struct{}
func (f *ISBN13Function) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) {
resp.Name = "isbn13"
}
func (f *ISBN13Function) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) {
resp.Definition = function.Definition{
Summary: "Convert an ISBN-10 to an ISBN-13",
Description: "Accepts an ISBN-10 with or without hyphens and returns the ISBN-13 without hyphens.",
Parameters: []function.Parameter{
function.StringParameter{Name: "isbn", Description: "The ISBN-10 to convert."},
},
Return: function.StringReturn{},
}
}
func (f *ISBN13Function) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) {
var isbn string
resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Get(ctx, &isbn))
if resp.Error != nil {
return
}
digits := strings.ReplaceAll(isbn, "-", "")
if len(digits) != 10 {
resp.Error = function.ConcatFuncErrors(resp.Error,
function.NewArgumentFuncError(0, "an ISBN-10 has exactly 10 characters"))
return
}
// Prefix with 978, drop the old check digit and compute the new one.
body := "978" + digits[:9]
sum := 0
for i, r := range body {
d := int(r - '0')
if i%2 == 1 {
d *= 3
}
sum += d
}
check := (10 - sum%10) % 10
resp.Error = function.ConcatFuncErrors(resp.Error,
resp.Result.Set(ctx, body+string(rune('0'+check))))
}
The function is pure: it must not call the API, because core may evaluate it during validation with no provider
configured. A function takes the name isbn13 and is called as provider::bookshelf::isbn13("0-441-17271-7"). The
implementation follows the documentation’s structure; the checksum arithmetic is ordinary ISBN-13 maths written for this
page and is not taken from a HashiCorp page.
Actions
An action is a provider-defined operation outside the create, read, update and delete model: invoke a function,
invalidate a cache, rebuild an index. Practitioners declare it with an action block and run it either with
terraform apply -invoke=action.TYPE.NAME or from a resource’s lifecycle { action_trigger { ... } } (practitioner
view in Provisioners, Actions and Post-Apply
Operations). The Framework introduced the action.Action interface in 1.16.0, available with Terraform 1.14 and
later, and the documentation says actions cannot yet modify resource state.
An action implements Metadata, Schema (input attributes only: an action has no output attributes) and Invoke.
Provider data arrives through ActionData in Configure and the optional action.ActionWithConfigure interface.
Invoke runs through the InvokeAction remote procedure call, reads the configuration and reports progress with
resp.SendProgress, which streams messages to the practitioner. Optional ActionWithValidateConfig does offline
checks and ActionWithModifyPlan does online validation during the PlanAction call.
package provider
import (
"context"
"fmt"
"github.com/example/terraform-provider-bookshelf/internal/bookshelf"
"github.com/hashicorp/terraform-plugin-framework/action"
"github.com/hashicorp/terraform-plugin-framework/action/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ action.Action = &ReindexAction{}
_ action.ActionWithConfigure = &ReindexAction{}
)
func NewReindexAction() action.Action { return &ReindexAction{} }
type ReindexAction struct{ client *bookshelf.Client }
type ReindexActionModel struct {
Shelf types.String `tfsdk:"shelf"`
}
func (a *ReindexAction) Metadata(_ context.Context, req action.MetadataRequest, resp *action.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_reindex"
}
func (a *ReindexAction) Schema(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Rebuilds the catalog search index.",
Attributes: map[string]schema.Attribute{
"shelf": schema.StringAttribute{
Optional: true,
MarkdownDescription: "Limit the rebuild to one shelf. Rebuilds everything when omitted.",
},
},
}
}
func (a *ReindexAction) Configure(_ context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*bookshelf.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected action configure type",
fmt.Sprintf("Expected *bookshelf.Client, got: %T.", req.ProviderData))
return
}
a.client = client
}
func (a *ReindexAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
var data ReindexActionModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
resp.SendProgress(action.InvokeProgressEvent{Message: "Rebuilding the search index..."})
if err := a.client.RebuildIndex(ctx, data.Shelf.ValueString()); err != nil { // omitted client method
resp.Diagnostics.AddError("Unable to rebuild the index", err.Error())
}
}
The matching configuration triggers the action after a book is created, and -invoke runs it on demand:
action "bookshelf_reindex" "catalog" {
config {
shelf = "science-fiction"
}
}
resource "terraform_data" "reindex_after_import" {
input = bookshelf_book.dune.id
lifecycle {
action_trigger {
events = [after_create]
actions = [action.bookshelf_reindex.catalog]
}
}
}
terraform apply -invoke=action.bookshelf_reindex.catalog
Source: Terraform Docs — Invoke actions
List resources and other components
The Framework has two further component types that this provider does not use. List resources (list.ListResource,
Framework 1.16.0, Terraform 1.14 and later) let a provider enumerate existing remote objects for terraform query
(see Import and Query). Resource identity (resource.ResourceWithIdentity,
added in Framework 1.15.0, Terraform 1.12 and later) stores an immutable identity next to a resource’s state and enables
importing by identity in an import block. State stores (ProviderWithStateStores, Framework 1.18.0) let a provider
store Terraform state, and the changelog calls the support experimental and without compatibility promises until
state_store is generally available in core.
Running the provider locally
You do not need a registry to use a provider you are developing. The CLI configuration file has a
provider_installation block whose dev_overrides maps a provider’s source address to a directory that holds the
provider binary. Terraform then uses that directory instead of any installed or downloaded copy.
# ~/.terraformrc, or a separate file selected with TF_CLI_CONFIG_FILE
provider_installation {
dev_overrides {
"example/bookshelf" = "/home/developer/go/bin"
}
# For all other providers, install them directly from their origin registries.
direct {}
}
go install . # builds and writes terraform-provider-bookshelf to GOBIN
export TF_CLI_CONFIG_FILE=$HOME/dev.tfrc # use the override only in this shell
export BOOKSHELF_ENDPOINT=https://bookshelf.example.com
export BOOKSHELF_TOKEN=... # placeholder: take it from your secret manager
terraform plan
The rules from the documentation:
-
the
dev_overridesblock must be first inprovider_installationand overrides every other installation method; -
it works in Terraform 0.14 and later;
-
if you omit
direct {}, no other provider can be installed; -
the override directory should hold what the provider’s release zip would hold; at minimum an executable named with a
terraform-provider-prefix; -
the override disables version and checksum verification for that provider;
-
terraform initstill selects a published version for the lock file, but other commands ignore the lock file entry for the overridden provider; -
it is a development aid, may change, and is not a general way to install a released provider from disk (use a
filesystem_mirrorfor that); -
to enable it only in some shells, put the block in a separate file and set
TF_CLI_CONFIG_FILE.
The debugging page also notes that with dev_overrides you do not need to run terraform init. Remove the override
and run terraform init -upgrade after you publish a release.
Debugging a provider
Three techniques are documented: log statements with tflog, development overrides with a real configuration, and a
debugger. For a debugger, build without optimisation (the compiler flags -N -l, shown in the listing below), start the provider with the
-debug flag defined in main.go and Delve, and give Terraform the TF_REATTACH_PROVIDERS value that the provider
prints:
go build -gcflags="all=-N -l"
dlv exec --accept-multiclient --continue --headless ./terraform-provider-bookshelf -- -debug
# copy the TF_REATTACH_PROVIDERS line the provider prints, then:
TF_REATTACH_PROVIDERS='{...}' terraform apply
Terraform does not start the provider in this mode, so you start it yourself, and the same process serves the whole command instead of being restarted for each graph walk. The page requires Terraform 0.12.26 or later and says it does not work with multiple provider aliases that have different configurations.
Acceptance tests
Provider tests come in two kinds. Unit tests are ordinary Go tests for pure logic (the ISBN arithmetic, the client
against an httptest server). Acceptance tests use the terraform-plugin-testing module to run real Terraform
configurations through real plan, apply, refresh and destroy cycles against the provider compiled in memory, so they
catch exactly the schema and plan bugs unit tests cannot see. The module is separate from the SDKs and supports the
Framework and SDKv2.
The requirements from the documentation are a recent stable Go, a Terraform CLI (0.12.26 or later is the documented
minimum), network access to the provider’s API, authorised credentials and the TF_ACC environment variable set to any
value; without TF_ACC the acceptance tests are skipped, which keeps go test ./... from creating real objects.
Install the CLI on PATH, or point TF_ACC_TERRAFORM_PATH at a binary, because otherwise the framework downloads the
latest CLI on every test run; TF_ACC_TERRAFORM_VERSION selects a version to install. Use a separate account or
namespace for tests.
A test registers the provider through a provider factory map and runs steps. This version follows the scaffolding
repository’s provider_test.go and example_resource_test.go:
package provider
import (
"fmt"
"testing"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
)
// The factory is called for each Terraform CLI command to create a provider
// server that the CLI connects to.
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"bookshelf": providerserver.NewProtocol6WithError(New("test")()),
}
func testAccPreCheck(t *testing.T) {
// Fail early when the environment is not ready.
if v := os.Getenv("BOOKSHELF_ENDPOINT"); v == "" {
t.Fatal("BOOKSHELF_ENDPOINT must be set for acceptance tests")
}
}
func TestAccBookResource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and read
{
Config: testAccBookConfig("Dune", "unsorted"),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue("bookshelf_book.test", tfjsonpath.New("title"), knownvalue.StringExact("Dune")),
statecheck.ExpectKnownValue("bookshelf_book.test", tfjsonpath.New("shelf"), knownvalue.StringExact("unsorted")),
},
},
// Import
{
ResourceName: "bookshelf_book.test",
ImportState: true,
ImportStateVerify: true,
},
// In-place update: the plan must be an update, not a replacement
{
Config: testAccBookConfig("Dune Messiah", "science-fiction"),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("bookshelf_book.test", plancheck.ResourceActionUpdate),
},
},
},
// Destroy runs automatically at the end of the test case
},
})
}
func testAccBookConfig(title, shelf string) string {
return fmt.Sprintf(`
resource "bookshelf_book" "test" {
title = %[1]q
author = "Frank Herbert"
isbn = "9780441172719"
shelf = %[2]q
}
`, title, shelf)
}
(The listing needs "os" in its imports; the plancheck assertions are an addition of this page and use the module’s
plancheck package.) Run the tests with TF_ACC=1 go test -v -cover -timeout 120m ./..., which is the scaffolding
GNUmakefile testacc target. Points to remember:
-
Each step is a complete apply. A step with
Configapplies it and then plans again to prove the result is stable; that non-empty-plan check is what catches "known after apply" mistakes. -
Import steps need
ImportStateVerifywithImportStateVerifyIgnorefor attributes the API cannot return. -
State and plan checks (
statecheck,plancheck,knownvalue,tfjsonpath) replace the SDKv2-eraTestCheckFunchelpers for new tests; theresource.TestCheckResourceAttrfamily still exists. -
Ephemeral resources cannot be inspected in state, so the scaffolding repository adds the
echoproviderprovider to the factory map, which copies an ephemeral value into state for assertions. -
Clean up. Real tests need
CheckDestroyfunctions and, for flaky leftovers, sweepers.
The module’s own README records its support policy: it follows Go’s, supporting the two latest Go major releases, which meant Go 1.25 or later when checked.
Acceptance tests can run against OpenTofu by pointing TF_ACC_TERRAFORM_PATH at a tofu binary; that behaviour and the
related provider-namespace variable were not confirmed on a primary page for this write-up, so check the OpenTofu and
terraform-plugin-testing documentation before relying on it.
Documentation with tfplugindocs
The public registry renders documentation from a docs/ directory of Markdown files in the repository: an index.md
for the provider and one page per resource, data source, function, ephemeral resource and action. Writing them by hand
drifts out of date, so providers generate them with tfplugindocs. It builds the provider, collects the schema with
terraform providers schema -json and renders the pages from the schema descriptions, your example files and optional
templates.
//go:build generate
package tools
import (
_ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs"
)
// Format Terraform code for use in documentation.
//go:generate terraform fmt -recursive ../examples/
// Generate documentation.
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir .. -provider-name bookshelf
make generate (which runs cd tools; go generate ./...) then rewrites docs/. The tool’s three subcommands are
generate (the default), validate and migrate. Its inputs follow a fixed layout:
| Path | Contents |
|---|---|
|
The provider configuration example shown on the index page. |
|
The resource example and the import command. The path includes the full resource type name. |
|
The data source example. |
|
A function example. Function paths do not include the provider prefix. |
|
Examples for the newer components (the scaffolding repository ships |
|
Optional |
|
The generated output, to be committed. |
Source: hashicorp/terraform-plugin-docs
Useful generate flags are --provider-dir, --provider-name (the name used in configuration; defaults to the
directory name without the terraform-provider- prefix), --rendered-provider-name, --examples-dir,
--website-source-dir, --rendered-website-dir, --providers-schema (skip the build) and --tf-version. The
validate subcommand checks the docs against the registry rules and reports every failure: invalid
subdirectories, a mix of legacy website/docs and docs, files above the registry size limit, bad extensions, bad
front matter and mismatches between the schema and the files. The README recommends the latest Terraform version, and
notes that some outputs need a minimum: functions 1.8.0, ephemeral resources 1.10.0, actions and list resources 1.14.0
and state stores 1.16.0. Because the descriptions are rendered into the pages, write MarkdownDescription for every
attribute; set a Description on id, or the generated page uses a generic one.
Source: hashicorp/terraform-plugin-docs
CI usually runs make generate and fails if git diff --exit-code shows changes, so docs cannot go stale.
Releasing with GoReleaser and GPG
The registry installs providers from release assets, not from source, so a release must contain precisely named,
checksummed and signed files. Automating that with GoReleaser is the recommended path, and the scaffolding repository
ships a working .goreleaser.yml. The listing below is an abbreviated copy of that file as of this check, with the
platform ignore list trimmed.
# .goreleaser.yml (abbreviated from terraform-provider-scaffolding-framework)
version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
mod_timestamp: '{{ .CommitTimestamp }}'
flags:
- -trimpath
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
goos: [freebsd, windows, linux, darwin]
goarch: [amd64, '386', arm, arm64, s390x]
ignore:
- { goos: darwin, goarch: '386' }
- { goos: darwin, goarch: s390x }
- { goos: freebsd, goarch: s390x }
- { goos: windows, goarch: arm }
- { goos: windows, goarch: s390x }
binary: '{{ .ProjectName }}_v{{ .Version }}'
archives:
- formats: [zip]
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
checksum:
extra_files:
- glob: 'terraform-registry-manifest.json'
name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS'
algorithm: sha256
signs:
- artifacts: checksum
args:
- "--batch"
- "--local-user"
- "{{ .Env.GPG_FINGERPRINT }}"
- "--output"
- "${signature}"
- "--detach-sign"
- "${artifact}"
release:
extra_files:
- glob: 'terraform-registry-manifest.json'
name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
changelog:
disable: true
Each section exists because of a registry requirement:
-
CGO_ENABLED=0,-trimpathand the-s -wlinker flags give static, reproducible binaries that run on hosts such as HCP Terraform runners where nobody can install libraries; the-X main.version={{.Version}}flag fills theversionvariable frommain.go. -
The archive name is
terraform-provider-NAME_VERSION_OS_ARCH.zipand the binary inside isterraform-provider-NAME_vVERSION, exactly as the publishing page demands. -
The checksum file
terraform-provider-NAME_VERSION_SHA256SUMSlists SHA-256 checksums for the zips and the manifest file. -
signswrites a detached signature of that checksum file withgpg --batch --local-user "$GPG_FINGERPRINT" --detach-sign, producing the.sigfile. The registry requires a binary, not ASCII-armoured, signature. -
The manifest
terraform-registry-manifest.jsonis uploaded to the release under a version-stamped name.
The release workflow in the scaffolding repository runs on tags that match v*, checks the repository out with full
history (fetch-depth: 0, so GoReleaser can see older tags), sets Go up from go.mod, imports the GPG key with
crazy-max/ghaction-import-gpg and runs goreleaser/goreleaser-action with release --clean. The workflow needs
permissions: contents: write, because GitHub counts creating releases and uploading assets as writing contents. The
action versions are pinned by commit in that file; the tags it lists were actions/checkout 7.0.1,
actions/setup-go 7.0.0, crazy-max/ghaction-import-gpg 7.0.0 and goreleaser/goreleaser-action 7.3.0.
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
cache: true
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v7
id: import_gpg
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
(Floating major tags are used here for readability; the scaffolding file pins full commit hashes, which is the safer habit for a workflow that holds a signing key. Store the private key and its passphrase as repository secrets, never in the repository.)
Publishing to a registry
The Terraform Registry
The public Terraform Registry publishes providers from public GitHub repositories. The requirements on the publishing page:
-
the repository is public and named
terraform-provider-NAMEwith lowercase letters only; -
a
terraform-registry-manifest.jsonat the repository root with valid JSON; it is renamed toterraform-provider-NAME_VERSION_manifest.jsonin the release; -
releases are tagged with a semantic version prefixed by
v(for examplev1.2.3; prereleases use a hyphen, for examplev1.2.3-pre), and no branch may share the tag’s name; -
the release is published, not a draft, and contains one zip per operating system and architecture, the manifest, the
SHA256SUMSfile and its binary.sigsignature; -
a released version must never be replaced, because that causes checksum errors; publish a new version instead;
-
documentation: an
index.mdoverview and a document per resource, data source and function.
The manifest tells the registry which protocol versions the provider speaks:
{
"version": 1,
"metadata": {
"protocol_versions": ["6.0"]
}
}
Here version is the manifest format version and not the provider version. The documentation lists ["5.0"] for
SDKv2 providers, ["6.0"] for the Framework unless it is configured for protocol 5, and says the default when the
manifest is omitted is ["5.0"], which is why a Framework provider that forgets the manifest is advertised as protocol 5
(the registry uses this metadata for compatibility during discovery).
The signing key is a GPG key that the registry can verify; it accepts RSA or DSA keys, not the default ECC type. The steps are:
-
generate the key and export the public key in ASCII-armour format;
-
sign in to the registry with a GitHub account and add the public key under User Settings > Signing Keys, either for your namespace or for an organisation in which you are an admin;
-
choose Publish > Provider, then the organisation and repository;
-
the registry creates a webhook on GitHub
releaseevents so that new tags appear automatically; if the webhook is missing or broken, delete the existing registry webhooks and use the Resync button on the provider’s settings page.
terraform init then verifies each downloaded release against the registered key; the practitioner side of that is in
Providers. Problems are reported to the registry team by email, as the page
describes.
A provider that is not on the public registry can still be distributed: through a private registry in HCP Terraform
(see HCP Terraform and Terraform Enterprise), through a
filesystem or network mirror, or through the same dev_overrides shown above for development only.
The OpenTofu Registry
OpenTofu has its own registry, and publishing there is a separate step. Its repository states that you add a provider,
module or GPG key by submitting an issue with one of its templates (a provider submission and a provider signing-key
submission), and that published provider and module versions are treated as immutable and generally not removed. A
provider built as above (static binaries, signed SHA256SUMS, a version-stamped manifest) is the same artefact for both
registries, so the release process does not change.
Source: OpenTofu Registry
What changed recently
| Change | Version | Notes |
|---|---|---|
Provider-defined functions |
Framework 1.5.0 (preview), 1.8.0 (generally available); Terraform 1.8 |
Calling syntax |
Ephemeral resources |
Framework 1.13.0 (preview), 1.14.0 (generally available); Terraform 1.10 |
New |
Resource identity |
Framework 1.15.0; Terraform 1.12 |
|
Actions |
Framework 1.16.0; Terraform 1.14 |
New |
List resources |
Framework 1.16.0; Terraform 1.14 |
|
State stores |
Framework 1.18.0 |
|
Resource configuration generation |
Framework 1.19.0 |
The logic behind |
Test module Go requirement |
|
Updated to Go 1.25; builds on older Go may fail. |
Test module additions |
|
Query checks and filters, a |
Docs tool |
|
Latest release found. Supports docs for functions, ephemeral resources, actions, list resources and state stores. |
For the Terraform-side versions in this table see the Terraform v1.17 changelog and the earlier branch changelogs. The last two rows concern tooling around the Framework rather than the Framework itself.
Where older material is outdated
Books and blog posts written before 2023 describe providers built with SDKv2 only, and parts of that advice no longer apply to a new provider:
-
Which SDK. Older guides start from
schema.Resourceandhelper/schema. The documentation now recommends the Framework for new work and describes SDKv2 as legacy. -
Test helpers. The SDK’s
helper/resourcepackage was split out intoterraform-plugin-testing, and the scaffolding linter forbids the SDK imports in a Framework provider. -
Documentation layout. The legacy
website/docsdirectory is a registry-ingestion hazard;tfplugindocs migrateremoves it and generated docs live indocs/. -
Missing components. Ephemeral resources, provider-defined functions, actions, resource identity and list resources appear in no pre-2024 provider guide.
-
Protocol. Guides that say providers speak protocol 5 describe the SDKv2; the Framework’s default is protocol 6, and the manifest should say so.
Putting it together
A checklist from an empty directory to a published provider:
-
Copy the scaffolding repository, rename it
terraform-provider-NAMEand change the module path andAddress. -
Write the client, then the provider schema and
Configure, then one resource withCreate,Read,Update,DeleteandImportState. Add plan modifiers (UseStateForUnknownfor stable computed values,RequiresReplacefor immutable arguments) and aModifyPlanwhere a warning helps. -
Log with
tflog, mask secrets, and use private state only for non-secret bookkeeping. -
Add a data source, then the optional components your users need.
-
Install the provider with
go install, enabledev_overridesthroughTF_CLI_CONFIG_FILEand run real plans. -
Write acceptance tests with
terraform-plugin-testing, and run them in CI withTF_ACC=1against a throw-away account. -
Write
MarkdownDescriptioneverywhere, add examples underexamples/, runtfplugindocs generateandvalidate. -
Create the GPG key, register its public half with the registry, store the private half as a CI secret.
-
Tag
vX.Y.Z, let GoReleaser build, sign and publish the release, and check that the registry lists the new version.