# Neo4j Labs Terraform Provider Neo4jaura Terraform Provider

> Terraform Provider Development — Neo4j Aura API

- Skill: `tomevault-io/neo4j-labs-terraform-provider-neo4jaura-terraform-provider` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/neo4j-labs-terraform-provider-neo4jaura-terraform-provider`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/neo4j-labs-terraform-provider-neo4jaura-terraform-provider/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/neo4j-labs-terraform-provider-neo4jaura-terraform-provider

---


# 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.

```go
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)

```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

```go
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

```go
// 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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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:

```go
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:

```go
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

```go
// 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.

```go
// 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()` |

```go
// 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`.

```go
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.

```go
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

```go
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:

```go
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:

```bash
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.

```go
// 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
//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

```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](https://developer.hashicorp.com/terraform/plugin/framework)
- [Provider Design Principles](https://developer.hashicorp.com/terraform/plugin/best-practices/hashicorp-provider-design-principles)
- [Naming](https://developer.hashicorp.com/terraform/plugin/best-practices/naming)
- [Sensitive state](https://developer.hashicorp.com/terraform/plugin/best-practices/sensitive-state)
- [terraform-plugin-testing](https://github.com/hashicorp/terraform-plugin-testing)
- [Scaffolding repo](https://github.com/hashicorp/terraform-provider-scaffolding-framework)
- See also: `aura-client` skill (Aura Go client API), `golang-patterns` skill (Go idioms)

---
> Source: [neo4j-labs/terraform-provider-neo4jaura](https://github.com/neo4j-labs/terraform-provider-neo4jaura) — distributed by [TomeVault](https://tomevault.io).
<!-- tomevault:4.0:skill_md:2026-05-22 -->

