terraform-provider

star 7

Comprehensive guide for developing a Terraform provider in Go against the Neo4j Aura API, using the modern terraform-plugin-framework. Use this skill whenever the user is writing, reviewing, structuring, testing, or debugging any part of a Terraform provider codebase — including resource/data source definitions, schema design, CRUD operations, error handling, import support, state upgrades, acceptance testing, and documentation. Trigger on any mention of "terraform provider", "plugin framework", "resource schema", "data source", "acceptance test", "tfproviderlint", "terraform import", or "state upgrade" in the context of provider development. Also trigger when reviewing Go code that imports terraform-plugin-framework packages.

neo4j-labs By neo4j-labs schedule Updated 5/9/2026

name: terraform-provider description: > Comprehensive guide for developing a Terraform provider in Go against the Neo4j Aura API, using the modern terraform-plugin-framework. Use this skill whenever the user is writing, reviewing, structuring, testing, or debugging any part of a Terraform provider codebase — including resource/data source definitions, schema design, CRUD operations, error handling, import support, state upgrades, acceptance testing, and documentation. Trigger on any mention of "terraform provider", "plugin framework", "resource schema", "data source", "acceptance test", "tfproviderlint", "terraform import", or "state upgrade" in the context of provider development. Also trigger when reviewing Go code that imports terraform-plugin-framework packages.

Terraform Provider Development — Neo4j Aura API

How to use this skill

Read this file top-to-bottom before writing any provider code. Each section builds on the previous one. Code samples are complete and directly usable — adapt names, don't invent patterns that aren't shown here. When something is marked RULE, it is non-negotiable.


Guiding Principles

  1. Single responsibility — The Aura provider manages only Aura management-plane resources.
  2. Mirror the API — Resource names, attribute names, and structure follow the Aura API unless doing so degrades UX.
  3. Declarative first — Resources represent desired state. Side-effect-only operations belong in data sources, not managed resources.
  4. Always support terraform import — Every managed resource must implement ImportState. Brownfield environments depend on it.
  5. Plan accuracy — What plan shows must match what apply produces. Use PlanModifiers to annotate computed-but-known-after-apply fields correctly.

Project Layout

terraform-provider-aura/
├── internal/
│   └── provider/
│       ├── provider.go              # Provider struct, Configure(), Resources(), DataSources()
│       ├── provider_test.go         # testAccProviderFactories, testAccPreCheck
│       ├── helpers.go               # isNotFound, shared polling helpers
│       ├── instance_resource.go
│       ├── instance_resource_test.go
│       ├── instance_data_source.go
│       └── instance_data_source_test.go
├── examples/
│   └── resources/aura_instance/
│       └── resource.tf
├── docs/                            # Generated by tfplugindocs — do not edit by hand
├── main.go
├── GNUmakefile
└── .goreleaser.yml

main.go

RULE: main.go must implement a debug flag and use providerserver.Serve. Never put business logic here.

package main

import (
    "context"
    "flag"
    "log"

    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    "terraform-provider-aura/internal/provider"
)

var version = "dev"

func main() {
    var debug bool
    flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers")
    flag.Parse()

    opts := providerserver.ServeOpts{
        Address: "registry.terraform.io/neo4j/aura",
        Debug:   debug,
    }

    err := providerserver.Serve(context.Background(), provider.New(version), opts)
    if err != nil {
        log.Fatal(err.Error())
    }
}

Provider Implementation (provider.go)

package provider

import (
    "context"
    "os"

    "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"
    "github.com/hashicorp/terraform-plugin-framework/types"
    aura "github.com/LackOfMorals/aura-client"
)

var _ provider.Provider = &AuraProvider{}

type AuraProvider struct{ version string }

// New is the entry point called from main.go.
func New(version string) func() provider.Provider {
    return func() provider.Provider {
        return &AuraProvider{version: version}
    }
}

type AuraProviderModel struct {
    ClientID     types.String `tfsdk:"client_id"`
    ClientSecret types.String `tfsdk:"client_secret"`
}

func (p *AuraProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
    resp.TypeName = "aura"
    resp.Version = p.version
}

func (p *AuraProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        MarkdownDescription: "Provider for managing Neo4j Aura resources via the Aura management API.",
        Attributes: map[string]schema.Attribute{
            "client_id": schema.StringAttribute{
                Optional:    true,
                Description: "Aura API client ID. Falls back to the AURA_CLIENT_ID environment variable.",
            },
            "client_secret": schema.StringAttribute{
                Optional:    true,
                Sensitive:   true,
                Description: "Aura API client secret. Falls back to AURA_CLIENT_SECRET environment variable.",
            },
        },
    }
}

func (p *AuraProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
    var config AuraProviderModel
    resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
    if resp.Diagnostics.HasError() {
        return
    }

    // Config block takes precedence; env var is the fallback.
    // Must check IsUnknown() as well as IsNull() — unknown means value not yet resolved at plan time.
    clientID := os.Getenv("AURA_CLIENT_ID")
    if !config.ClientID.IsNull() && !config.ClientID.IsUnknown() {
        clientID = config.ClientID.ValueString()
    }
    clientSecret := os.Getenv("AURA_CLIENT_SECRET")
    if !config.ClientSecret.IsNull() && !config.ClientSecret.IsUnknown() {
        clientSecret = config.ClientSecret.ValueString()
    }

    if clientID == "" {
        resp.Diagnostics.AddError("Missing client_id",
            "Set client_id in the provider block or the AURA_CLIENT_ID environment variable.")
        return
    }
    if clientSecret == "" {
        resp.Diagnostics.AddError("Missing client_secret",
            "Set client_secret in the provider block or the AURA_CLIENT_SECRET environment variable.")
        return
    }

    client, err := aura.NewClient(aura.WithCredentials(clientID, clientSecret))
    if err != nil {
        resp.Diagnostics.AddError("Failed to configure Aura client", err.Error())
        return
    }

    // Pass client to all resources and data sources via their Configure methods.
    resp.DataSourceData = client
    resp.ResourceData = client
}

func (p *AuraProvider) Resources(_ context.Context) []func() resource.Resource {
    return []func() resource.Resource{
        NewInstanceResource,
    }
}

func (p *AuraProvider) DataSources(_ context.Context) []func() datasource.DataSource {
    return []func() datasource.DataSource{
        NewInstanceDataSource,
    }
}

Resource Implementation

Every resource file must contain, in this order:

  1. Compile-time interface checks
  2. New<Resource> constructor
  3. Resource struct (holds the client)
  4. Model struct (maps schema to Go types)
  5. Metadata() — registers the type name with Terraform
  6. Schema() — declares all attributes
  7. Configure() — receives the client from the provider
  8. Create(), Read(), Update(), Delete(), ImportState()

Interface checks and constructor

package provider

import (
    "context"
    "errors"
    "fmt"
    "time"

    "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/schema/validator"
    "github.com/hashicorp/terraform-plugin-framework/types"
    "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
    aura "github.com/LackOfMorals/aura-client"
)

var (
    _ resource.Resource                = &InstanceResource{}
    _ resource.ResourceWithConfigure   = &InstanceResource{}
    _ resource.ResourceWithImportState = &InstanceResource{}
)

func NewInstanceResource() resource.Resource {
    return &InstanceResource{}
}

Resource struct and model struct

// InstanceResource holds provider-level dependencies injected by Configure().
type InstanceResource struct {
    client *aura.Client
}

// InstanceResourceModel maps the Terraform schema to Go types via tfsdk tags.
// Every attribute in Schema() must have a matching field here with the correct tfsdk tag.
// Missing or mismatched tags cause runtime panics, not compile errors.
type InstanceResourceModel struct {
    ID            types.String `tfsdk:"id"`
    Name          types.String `tfsdk:"name"`
    Region        types.String `tfsdk:"region"`
    CloudProvider types.String `tfsdk:"cloud_provider"`
    Status        types.String `tfsdk:"status"`
    ConnectionURL types.String `tfsdk:"connection_url"`
}

Metadata

RULE: resp.TypeName must be req.ProviderTypeName + "_" + <noun>. This is how Terraform matches the schema to the HCL resource block (resource "aura_instance" ...). Getting this wrong causes "resource type not found" errors at init time.

func (r *InstanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
    resp.TypeName = req.ProviderTypeName + "_instance"
}

Schema design — decision table

Use this table to choose the right combination of Required, Optional, Computed for each attribute. RULE: Required+Computed together is invalid and will panic.

What the attribute represents Required Optional Computed Extra
Must be set by user, no API default
User sets it; changing forces recreation RequiresReplace() plan modifier
User can set it; API has a default UseStateForUnknown() plan modifier
Read-only, returned by API (id, url) UseStateForUnknown() so plan shows known value
Sensitive credential or token (any) Also set Sensitive: true
Value from a fixed set (any) Add stringvalidator.OneOf(...)

RULE: Every attribute must have a Description. Without it tfplugindocs generates empty docs and tfproviderlint fails.

func (r *InstanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Description: "Manages a Neo4j Aura database instance.",
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed:    true,
                Description: "The Aura-assigned instance ID.",
                PlanModifiers: []planmodifier.String{
                    stringplanmodifier.UseStateForUnknown(),
                },
            },
            "name": schema.StringAttribute{
                Required:    true,
                Description: "Display name for the instance.",
            },
            "region": schema.StringAttribute{
                Required:    true,
                Description: "Cloud region (e.g. us-east-1). Changing this forces recreation.",
                PlanModifiers: []planmodifier.String{
                    stringplanmodifier.RequiresReplace(),
                },
            },
            "cloud_provider": schema.StringAttribute{
                Required:    true,
                Description: "Cloud provider. One of: aws, gcp, azure. Changing this forces recreation.",
                PlanModifiers: []planmodifier.String{
                    stringplanmodifier.RequiresReplace(),
                },
                Validators: []validator.String{
                    stringvalidator.OneOf("aws", "gcp", "azure"),
                },
            },
            "status": schema.StringAttribute{
                Computed:    true,
                Description: "Current lifecycle status (e.g. running, creating, deleting).",
            },
            "connection_url": schema.StringAttribute{
                Computed:    true,
                Description: "Bolt connection URL for the instance.",
                PlanModifiers: []planmodifier.String{
                    stringplanmodifier.UseStateForUnknown(),
                },
            },
        },
    }
}

Configure — receiving the client from the provider

RULE: Always check req.ProviderData == nil before type-asserting. The framework calls Configure before the provider's own Configure completes; a direct type-assert on nil panics.

func (r *InstanceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
    if req.ProviderData == nil {
        return
    }

    client, ok := req.ProviderData.(*aura.Client)
    if !ok {
        resp.Diagnostics.AddError(
            "Unexpected provider data type",
            fmt.Sprintf("Expected *aura.Client, got %T. This is a provider bug.", req.ProviderData),
        )
        return
    }

    r.client = client
}

Create

RULE: Write to resp.State immediately after the API call returns — before any async wait loop. If the wait times out or the process crashes, Terraform must know the resource exists so it can destroy it on the next run.

func (r *InstanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan InstanceResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    if resp.Diagnostics.HasError() {
        return
    }

    result, err := r.client.Instances.Create(ctx, aura.CreateInstanceConfigData{
        Name:          plan.Name.ValueString(),
        Region:        plan.Region.ValueString(),
        CloudProvider: plan.CloudProvider.ValueString(),
    })
    if err != nil {
        resp.Diagnostics.AddError("Error creating Aura instance", err.Error())
        return
    }

    // Write state before the async wait so the resource is tracked even if we crash.
    plan.ID = types.StringValue(result.ID)
    plan.Status = types.StringValue(result.Status)
    plan.ConnectionURL = types.StringValue(result.ConnectionURL)
    resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
    if resp.Diagnostics.HasError() {
        return
    }

    // Wait for the instance to reach a usable state.
    if err := waitForStatus(ctx, r.client, result.ID, "running", 30*time.Minute); err != nil {
        resp.Diagnostics.AddError("Aura instance did not reach running state", err.Error())
        return
    }

    // Re-read so the final status and URL are captured in state.
    diags := refreshInstanceState(ctx, r.client, &plan)
    resp.Diagnostics.Append(diags...)
    resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

Read

RULE: If the API returns 404, call resp.State.RemoveResource(ctx) and return without an error. This signals drift; Terraform will plan to recreate the resource. Adding an error on 404 breaks terraform refresh and import flows.

func (r *InstanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state InstanceResourceModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    if resp.Diagnostics.HasError() {
        return
    }

    instance, err := r.client.Instances.Get(ctx, state.ID.ValueString())
    if err != nil {
        if isNotFound(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        resp.Diagnostics.AddError(
            "Error reading Aura instance",
            fmt.Sprintf("Could not read instance %s: %s", state.ID.ValueString(), err.Error()),
        )
        return
    }

    state.Name = types.StringValue(instance.Name)
    state.Status = types.StringValue(instance.Status)
    state.ConnectionURL = types.StringValue(instance.ConnectionURL)
    // Do not overwrite state.Region or state.CloudProvider — they are RequiresReplace
    // and the API may normalise them differently than what the user typed.
    resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

Update

RULE: Read both req.Plan (desired) and req.State (current). Only send fields that changed. Computed-only fields (connection_url, status) must be preserved from state — never zero them out; the Update method is not responsible for refreshing them.

func (r *InstanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var plan, state InstanceResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    if resp.Diagnostics.HasError() {
        return
    }

    // Only name is mutable; region/cloud_provider carry RequiresReplace so they never reach Update.
    if !plan.Name.Equal(state.Name) {
        _, err := r.client.Instances.Update(ctx, state.ID.ValueString(), aura.UpdateInstanceData{
            Name: plan.Name.ValueString(),
        })
        if err != nil {
            resp.Diagnostics.AddError("Error updating Aura instance", err.Error())
            return
        }
    }

    // Preserve computed fields from state — the Update API doesn't return them.
    plan.ID = state.ID
    plan.Status = state.Status
    plan.ConnectionURL = state.ConnectionURL

    resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

Delete

RULE: After issuing the delete API call, poll until the resource returns 404. Returning immediately while the resource is still being deleted causes state corruption if Terraform tries to recreate it before deletion completes.

RULE: Treat 404 on the initial delete call as success — the resource is already gone.

func (r *InstanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
    var state InstanceResourceModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    if resp.Diagnostics.HasError() {
        return
    }

    err := r.client.Instances.Delete(ctx, state.ID.ValueString())
    if err != nil {
        if isNotFound(err) {
            return // Already gone — treat as success.
        }
        resp.Diagnostics.AddError("Error deleting Aura instance", err.Error())
        return
    }

    if err := waitForDeletion(ctx, r.client, state.ID.ValueString(), 30*time.Minute); err != nil {
        resp.Diagnostics.AddError("Aura instance did not finish deleting", err.Error())
    }
}

ImportState

For single-ID resources:

func (r *InstanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

For composite IDs (e.g. tenant_id/instance_id), parse manually:

func (r *InstanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    parts := strings.SplitN(req.ID, "/", 2)
    if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
        resp.Diagnostics.AddError(
            "Invalid import ID format",
            fmt.Sprintf("Expected 'tenant_id/instance_id', got %q", req.ID),
        )
        return
    }
    resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), parts[0])...)
    resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), parts[1])...)
}

Shared Helpers (helpers.go)

These functions are used across multiple resources. Put them in internal/provider/helpers.go.

isNotFound

// isNotFound returns true when err represents an HTTP 404 from the Aura API.
// Adjust the type assertion to match the error type aura-client actually returns.
func isNotFound(err error) bool {
    if err == nil {
        return false
    }
    var apiErr *aura.APIError
    if errors.As(err, &apiErr) {
        return apiErr.StatusCode == 404
    }
    return false
}

Async polling

RULE: Never use a bare time.Sleep loop. Always select on ctx.Done() so the user can cancel and so Terraform's operation timeout is respected.

// waitForStatus polls until the instance reaches targetStatus or the context expires.
// Call this after Create to wait for "running", after resize to wait for "running" again.
func waitForStatus(ctx context.Context, client *aura.Client, id, targetStatus string, timeout time.Duration) error {
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    ticker := time.NewTicker(15 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return fmt.Errorf("timed out after %s waiting for instance %s to reach status %q", timeout, id, targetStatus)
        case <-ticker.C:
            instance, err := client.Instances.Get(ctx, id)
            if err != nil {
                return fmt.Errorf("error polling instance %s: %w", id, err)
            }
            if instance.Status == targetStatus {
                return nil
            }
            if instance.Status == "failed" {
                return fmt.Errorf("instance %s entered failed state while waiting for %q", id, targetStatus)
            }
            // Any other status (creating, updating) — continue polling.
        }
    }
}

// waitForDeletion polls until the instance returns 404.
func waitForDeletion(ctx context.Context, client *aura.Client, id string, timeout time.Duration) error {
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    ticker := time.NewTicker(15 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return fmt.Errorf("timed out after %s waiting for instance %s to be deleted", timeout, id)
        case <-ticker.C:
            _, err := client.Instances.Get(ctx, id)
            if isNotFound(err) {
                return nil
            }
            if err != nil {
                return fmt.Errorf("error polling instance %s deletion: %w", id, err)
            }
            // Instance still exists — continue polling.
        }
    }
}

// refreshInstanceState fetches the latest instance data and writes it into model.
// Used as a shared helper by Create and Read.
func refreshInstanceState(ctx context.Context, client *aura.Client, model *InstanceResourceModel) diag.Diagnostics {
    var diags diag.Diagnostics
    instance, err := client.Instances.Get(ctx, model.ID.ValueString())
    if err != nil {
        diags.AddError("Error refreshing instance state",
            fmt.Sprintf("Instance %s: %s", model.ID.ValueString(), err.Error()))
        return diags
    }
    model.Name = types.StringValue(instance.Name)
    model.Status = types.StringValue(instance.Status)
    model.ConnectionURL = types.StringValue(instance.ConnectionURL)
    return diags
}

Handling null/unknown in Optional fields

types.String has three states. Always check before calling .ValueString() on optional fields.

State Check Meaning
Set by user !v.IsNull() && !v.IsUnknown() Call .ValueString() safely
Not in config v.IsNull() Omit from API call; write types.StringNull() back
Plan-time unknown v.IsUnknown() Value not yet resolved; do not call .ValueString()
// Sending an optional field to the API — only include if the user provided it.
if !plan.SomeOptionalField.IsNull() && !plan.SomeOptionalField.IsUnknown() {
    apiRequest.SomeField = plan.SomeOptionalField.ValueString()
}

// Writing an optional field back to state — handle empty API response gracefully.
if instance.SomeField == "" {
    state.SomeOptionalField = types.StringNull()
} else {
    state.SomeOptionalField = types.StringValue(instance.SomeField)
}

// For API fields that return a pointer — ValueStringPointer handles nil → null automatically.
state.SomeField = types.StringPointerValue(instance.SomeFieldPtr)

Data Source Implementation

Data sources follow the same pattern as resources but implement datasource.DataSource and only have a Read method (no Create/Update/Delete). The Configure method is identical in structure to a resource's Configure.

package provider

var (
    _ datasource.DataSource              = &InstanceDataSource{}
    _ datasource.DataSourceWithConfigure = &InstanceDataSource{}
)

func NewInstanceDataSource() datasource.DataSource { return &InstanceDataSource{} }

type InstanceDataSource struct{ client *aura.Client }

type InstanceDataSourceModel struct {
    ID            types.String `tfsdk:"id"`
    Name          types.String `tfsdk:"name"`
    Status        types.String `tfsdk:"status"`
    ConnectionURL types.String `tfsdk:"connection_url"`
}

func (d *InstanceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
    resp.TypeName = req.ProviderTypeName + "_instance"
}

func (d *InstanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Description: "Fetches a single Aura instance by ID.",
        Attributes: map[string]schema.Attribute{
            "id":             schema.StringAttribute{Required: true, Description: "Instance ID to look up."},
            "name":           schema.StringAttribute{Computed: true, Description: "Display name of the instance."},
            "status":         schema.StringAttribute{Computed: true, Description: "Current lifecycle status."},
            "connection_url": schema.StringAttribute{Computed: true, Description: "Bolt connection URL."},
        },
    }
}

func (d *InstanceDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
    if req.ProviderData == nil {
        return
    }
    client, ok := req.ProviderData.(*aura.Client)
    if !ok {
        resp.Diagnostics.AddError("Unexpected provider data type",
            fmt.Sprintf("Expected *aura.Client, got %T.", req.ProviderData))
        return
    }
    d.client = client
}

func (d *InstanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
    var data InstanceDataSourceModel
    resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
    if resp.Diagnostics.HasError() {
        return
    }

    instance, err := d.client.Instances.Get(ctx, data.ID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Error reading Aura instance",
            fmt.Sprintf("Could not read instance %s: %s", data.ID.ValueString(), err.Error()))
        return
    }

    data.Name = types.StringValue(instance.Name)
    data.Status = types.StringValue(instance.Status)
    data.ConnectionURL = types.StringValue(instance.ConnectionURL)
    resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

State Upgrade

When you make a breaking schema change (rename an attribute, change a type), increment schema.Schema.Version and implement ResourceWithUpgradeState.

RULE: Never make API calls inside a StateUpgrader. It only transforms state data in memory.

var _ resource.ResourceWithUpgradeState = &InstanceResource{}

// In Schema(), set Version: 1 (or higher).
func (r *InstanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Version:     1,
        Description: "Manages a Neo4j Aura database instance.",
        Attributes:  map[string]schema.Attribute{ /* ... current attributes ... */ },
    }
}

func (r *InstanceResource) UpgradeState(_ context.Context) map[int64]resource.StateUpgrader {
    return map[int64]resource.StateUpgrader{
        // Migrate from schema version 0 → 1 (e.g. "display_name" renamed to "name").
        0: {
            PriorSchema: &schema.Schema{
                Attributes: map[string]schema.Attribute{
                    "id":           schema.StringAttribute{Computed: true},
                    "display_name": schema.StringAttribute{Optional: true}, // old attribute
                },
            },
            StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
                type modelV0 struct {
                    ID          types.String `tfsdk:"id"`
                    DisplayName types.String `tfsdk:"display_name"`
                }
                var prior modelV0
                resp.Diagnostics.Append(req.State.Get(ctx, &prior)...)
                if resp.Diagnostics.HasError() {
                    return
                }
                // No API calls — transform state data only.
                upgraded := InstanceResourceModel{
                    ID:   prior.ID,
                    Name: prior.DisplayName, // renamed field
                }
                resp.Diagnostics.Append(resp.State.Set(ctx, upgraded)...)
            },
        },
    }
}

Testing

provider_test.go — shared setup

package provider_test

import (
    "os"
    "testing"

    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    "github.com/hashicorp/terraform-plugin-go/tfprotov6"
    "terraform-provider-aura/internal/provider"
)

// testAccProviderFactories is used in every TestCase.
var testAccProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
    "aura": providerserver.NewProtocol6WithError(provider.New("test")()),
}

// testAccPreCheck validates that required env vars are set before any acceptance test runs.
func testAccPreCheck(t *testing.T) {
    t.Helper()
    if os.Getenv("AURA_CLIENT_ID") == "" {
        t.Fatal("AURA_CLIENT_ID must be set for acceptance tests")
    }
    if os.Getenv("AURA_CLIENT_SECRET") == "" {
        t.Fatal("AURA_CLIENT_SECRET must be set for acceptance tests")
    }
}

instance_resource_test.go — required test scenarios

Every resource must cover four scenarios:

package provider_test

import (
    "fmt"
    "testing"

    "github.com/hashicorp/terraform-plugin-testing/helper/resource"
    "github.com/hashicorp/terraform-plugin-testing/terraform"
)

// 1. Basic: create, verify computed attributes, verify destroy.
func TestAccInstanceResource_basic(t *testing.T) {
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProviderFactories,
        CheckDestroy:             testAccCheckInstanceDestroyed,
        Steps: []resource.TestStep{
            {
                Config: testAccInstanceConfig("tf-acc-basic"),
                Check: resource.ComposeAggregateTestCheckFunc(
                    resource.TestCheckResourceAttr("aura_instance.test", "name", "tf-acc-basic"),
                    resource.TestCheckResourceAttrSet("aura_instance.test", "id"),
                    resource.TestCheckResourceAttrSet("aura_instance.test", "connection_url"),
                    resource.TestCheckResourceAttr("aura_instance.test", "status", "running"),
                ),
            },
            // 3. Import: confirm state round-trips correctly through import.
            {
                ResourceName:      "aura_instance.test",
                ImportState:       true,
                ImportStateVerify: true,
            },
        },
    })
}

// 2. Update: verify mutable fields are applied in-place without recreation.
func TestAccInstanceResource_rename(t *testing.T) {
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProviderFactories,
        CheckDestroy:             testAccCheckInstanceDestroyed,
        Steps: []resource.TestStep{
            {
                Config: testAccInstanceConfig("tf-acc-before"),
                Check:  resource.TestCheckResourceAttr("aura_instance.test", "name", "tf-acc-before"),
            },
            {
                Config: testAccInstanceConfig("tf-acc-after"),
                Check:  resource.TestCheckResourceAttr("aura_instance.test", "name", "tf-acc-after"),
            },
        },
    })
}

// 4. Disappears: instance deleted out-of-band; Terraform must detect drift and plan recreation.
func TestAccInstanceResource_disappears(t *testing.T) {
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProviderFactories,
        Steps: []resource.TestStep{
            {
                Config: testAccInstanceConfig("tf-acc-disappears"),
                Check:  testAccDeleteInstanceOutOfBand("aura_instance.test"),
                ExpectNonEmptyPlan: true,
            },
        },
    })
}

// --- Config helpers ---

func testAccInstanceConfig(name string) string {
    return fmt.Sprintf(`
resource "aura_instance" "test" {
  name           = %q
  region         = "us-east-1"
  cloud_provider = "aws"
}
`, name)
}

// --- Check helpers ---

// testAccCheckInstanceDestroyed verifies no test instances remain after the test.
// It is passed as CheckDestroy in TestCase and runs after Terraform destroy completes.
func testAccCheckInstanceDestroyed(s *terraform.State) error {
    for _, rs := range s.RootModule().Resources {
        if rs.Type != "aura_instance" {
            continue
        }
        // Call the Aura API directly with rs.Primary.ID.
        // Return an error if the instance still exists (non-404 response).
        _ = rs.Primary.ID
    }
    return nil
}

// testAccDeleteInstanceOutOfBand is a TestCheckFunc that deletes the instance via the API
// directly, bypassing Terraform. Used to simulate out-of-band deletion for the disappears test.
func testAccDeleteInstanceOutOfBand(resourceName string) resource.TestCheckFunc {
    return func(s *terraform.State) error {
        rs, ok := s.RootModule().Resources[resourceName]
        if !ok {
            return fmt.Errorf("resource %s not found in state", resourceName)
        }
        id := rs.Primary.ID
        // Call the Aura API directly to delete the instance by id.
        _ = id
        return nil
    }
}

Run acceptance tests:

TF_ACC=1 AURA_CLIENT_ID=xxx AURA_CLIENT_SECRET=yyy \
  go test ./internal/provider/... -v -run TestAcc -timeout 60m

Error Handling Rules

  • Use resp.Diagnostics.AddError(summary, detail) — never panic or log.Fatal.
  • summary is the short operator-facing message shown in terraform apply output (one sentence).
  • detail contains the raw error, resource ID, and anything useful for debugging.
  • Always include the resource ID in error messages so operators can find the affected resource.
// Good error message format:
resp.Diagnostics.AddError(
    "Error reading Aura instance",
    fmt.Sprintf("Could not read instance %s: %s", state.ID.ValueString(), err.Error()),
)

Naming Conventions

Item Convention Example
Repo terraform-provider-<name> terraform-provider-aura
Registry address registry.terraform.io/<org>/<name> registry.terraform.io/neo4j/aura
Resource type <provider>_<noun> snake_case aura_instance
Data source type <provider>_<noun> snake_case aura_instance
Resource file <noun>_resource.go instance_resource.go
Data source file <noun>_data_source.go instance_data_source.go
Attribute names snake_case, mirrors Aura API field names connection_url, cloud_provider
Acceptance tests TestAcc<Resource>_<scenario> TestAccInstanceResource_basic

Avoid provider name prefixes on attributes (aura_region → just region).


Versioning and Changelog

  • Semantic versioning: breaking schema changes → major, new resources → minor, bug fixes → patch.
  • Use changie — one entry per PR.
  • Bump schema.Schema.Version and implement ResourceWithUpgradeState for any breaking attribute-type change.

Documentation

Add //go:generate to provider.go to wire doc generation into make generate:

//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-name aura

Every Description and MarkdownDescription field in every schema feeds directly into the generated docs/ output. Keep them accurate — they are the user-facing documentation.


Makefile

default: fmt lint build

build:
    go build ./...

fmt:
    gofmt -s -w .
    terraform fmt -recursive ./examples/

lint:
    golangci-lint run
    tfproviderlint ./...

testacc:
    TF_ACC=1 go test ./internal/provider/... -v -timeout 60m -run TestAcc

generate:
    go generate ./...

install:
    go install .

References

Install via CLI
npx skills add https://github.com/neo4j-labs/terraform-provider-neo4jaura --skill terraform-provider
Repository Details
star Stars 7
call_split Forks 5
navigation Branch main
article Path SKILL.md
More from Creator