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
- Single responsibility — The Aura provider manages only Aura management-plane resources.
- Mirror the API — Resource names, attribute names, and structure follow the Aura API unless doing so degrades UX.
- Declarative first — Resources represent desired state. Side-effect-only operations belong in data sources, not managed resources.
- Always support
terraform import— Every managed resource must implementImportState. Brownfield environments depend on it. - Plan accuracy — What
planshows must match whatapplyproduces. UsePlanModifiersto 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:
- Compile-time interface checks
New<Resource>constructor- Resource struct (holds the client)
- Model struct (maps schema to Go types)
Metadata()— registers the type name with TerraformSchema()— declares all attributesConfigure()— receives the client from the providerCreate(),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)— neverpanicorlog.Fatal. summaryis the short operator-facing message shown interraform applyoutput (one sentence).detailcontains 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.Versionand implementResourceWithUpgradeStatefor 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
- Plugin Framework docs
- Provider Design Principles
- Naming
- Sensitive state
- terraform-plugin-testing
- Scaffolding repo
- See also:
aura-clientskill (Aura Go client API),golang-patternsskill (Go idioms)