| 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 implement ImportState. Brownfield environments depend on it.
- 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 }
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
}
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
}
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 Terraform
Schema() — declares all attributes
Configure() — receives the client from the provider
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
type InstanceResource struct {
client *aura.Client
}
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
}
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
}
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
}
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)
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
}
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
}
}
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
}
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
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.
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)
}
}
}
}
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)
}
}
}
}
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() |
if !plan.SomeOptionalField.IsNull() && !plan.SomeOptionalField.IsUnknown() {
apiRequest.SomeField = plan.SomeOptionalField.ValueString()
}
if instance.SomeField == "" {
state.SomeOptionalField = types.StringNull()
} else {
state.SomeOptionalField = types.StringValue(instance.SomeField)
}
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{}
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{ },
}
}
func (r *InstanceResource) UpgradeState(_ context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: {
PriorSchema: &schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true},
"display_name": schema.StringAttribute{Optional: true},
},
},
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
}
upgraded := InstanceResourceModel{
ID: prior.ID,
Name: prior.DisplayName,
}
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"
)
var testAccProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"aura": providerserver.NewProtocol6WithError(provider.New("test")()),
}
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"
)
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"),
),
},
{
ResourceName: "aura_instance.test",
ImportState: true,
ImportStateVerify: true,
},
},
})
}
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"),
},
},
})
}
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,
},
},
})
}
func testAccInstanceConfig(name string) string {
return fmt.Sprintf(`
resource "aura_instance" "test" {
name = %q
region = "us-east-1"
cloud_provider = "aws"
}
`, name)
}
func testAccCheckInstanceDestroyed(s *terraform.State) error {
for _, rs := range s.RootModule().Resources {
if rs.Type != "aura_instance" {
continue
}
_ = rs.Primary.ID
}
return nil
}
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
_ = 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.
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:
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