diff --git a/terraform/provider-development/skills/provider-resources-with-framework/SKILL.md b/terraform/provider-development/skills/provider-resources-with-framework/SKILL.md new file mode 100644 index 0000000..4fca551 --- /dev/null +++ b/terraform/provider-development/skills/provider-resources-with-framework/SKILL.md @@ -0,0 +1,543 @@ +--- +name: provider-development-with-framework +description: Implement Terraform Provider resources and data sources using the Plugin Framework. Use for Framework-only providers, or when authoring new resources in a combined mux provider. +metadata: + copyright: Copyright IBM Corp. 2026 + version: "0.0.1" +--- + +# Terraform Provider Resources — Plugin Framework + +## Overview + +This guide covers developing Terraform Provider resources and data sources using the [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework). Resources represent infrastructure objects that Terraform manages through Create, Read, Update, and Delete (CRUD) operations. + +**References:** +- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework) +- [Resource Development](https://developer.hashicorp.com/terraform/plugin/framework/resources) +- [Data Source Development](https://developer.hashicorp.com/terraform/plugin/framework/data-sources) + +## File Structure + +Resources follow the standard service package structure: + +``` +internal/service// +├── .go # Resource implementation +├── _test.go # Acceptance tests +├── _data_source.go # Data source (if applicable) +├── find.go # Finder functions +├── exports_test.go # Test exports +└── service_package_gen.go # Auto-generated registration +``` + +Documentation structure: +``` +website/docs/r/ +└── _.html.markdown # Resource documentation + +website/docs/d/ +└── _.html.markdown # Data source documentation +``` + +## Resource Structure + +```go +type resourceExample struct { + framework.ResourceWithConfigure +} + +func (r *resourceExample) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_example" +} + +func (r *resourceExample) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": framework.IDAttribute(), + "name": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + }, + }, + "arn": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} +``` + +## CRUD Operations + +### Create + +```go +func (r *resourceExample) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data resourceExampleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + conn := r.Meta().ExampleClient(ctx) + + input := &example.CreateExampleInput{ + Name: data.Name.ValueStringPointer(), + } + + output, err := conn.CreateExample(ctx, input) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Example", + fmt.Sprintf("Could not create example %s: %s", data.Name.ValueString(), err), + ) + return + } + + data.ID = types.StringPointerValue(output.Id) + data.ARN = types.StringPointerValue(output.Arn) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} +``` + +### Read + +```go +func (r *resourceExample) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data resourceExampleModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + conn := r.Meta().ExampleClient(ctx) + + output, err := findExampleByID(ctx, conn, data.ID.ValueString()) + if tfresource.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Example", + fmt.Sprintf("Could not read example %s: %s", data.ID.ValueString(), err), + ) + return + } + + data.Name = types.StringPointerValue(output.Name) + data.ARN = types.StringPointerValue(output.Arn) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} +``` + +### Update + +```go +func (r *resourceExample) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state resourceExampleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + conn := r.Meta().ExampleClient(ctx) + + if !plan.Description.Equal(state.Description) { + input := &example.UpdateExampleInput{ + Id: plan.ID.ValueStringPointer(), + Description: plan.Description.ValueStringPointer(), + } + + _, err := conn.UpdateExample(ctx, input) + if err != nil { + resp.Diagnostics.AddError( + "Error updating Example", + fmt.Sprintf("Could not update example %s: %s", plan.ID.ValueString(), err), + ) + return + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} +``` + +### Delete + +```go +func (r *resourceExample) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data resourceExampleModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + conn := r.Meta().ExampleClient(ctx) + + _, err := conn.DeleteExample(ctx, &example.DeleteExampleInput{ + Id: data.ID.ValueStringPointer(), + }) + + if tfresource.NotFound(err) { + return + } + + if err != nil { + resp.Diagnostics.AddError( + "Error deleting Example", + fmt.Sprintf("Could not delete example %s: %s", data.ID.ValueString(), err), + ) + return + } +} +``` + +## Schema Design + +### Attribute Types + +| Terraform Type | Framework Type | Use Case | +|---|---|---| +| `string` | `schema.StringAttribute` | Names, ARNs, IDs | +| `number` | `schema.Int64Attribute`, `schema.Float64Attribute` | Counts, sizes | +| `bool` | `schema.BoolAttribute` | Feature flags | +| `list` | `schema.ListAttribute` | Ordered collections | +| `set` | `schema.SetAttribute` | Unordered unique items | +| `map` | `schema.MapAttribute` | Key-value pairs | +| `object` | `schema.SingleNestedAttribute` | Complex nested config | + +### Plan Modifiers + +```go +// Force replacement when value changes +stringplanmodifier.RequiresReplace() + +// Preserve computed value across plans +stringplanmodifier.UseStateForUnknown() + +// Conditional replacement +stringplanmodifier.RequiresReplaceIf( + func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + // Custom logic + }, + "description", + "markdown description", +) +``` + +### Validators + +```go +// String validators +stringvalidator.LengthBetween(1, 255) +stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z0-9-]+$`), "must be lowercase alphanumeric with hyphens") +stringvalidator.OneOf("option1", "option2", "option3") + +// Int64 validators +int64validator.Between(1, 100) +int64validator.AtLeast(1) +int64validator.AtMost(1000) + +// List validators +listvalidator.SizeAtLeast(1) +listvalidator.SizeAtMost(10) +``` + +### Sensitive Attributes + +```go +"password": schema.StringAttribute{ + Required: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(8), + }, +}, +``` + +## State Management + +### Handling Resource Not Found + +```go +func findExampleByID(ctx context.Context, conn *example.Client, id string) (*example.Example, error) { + input := &example.GetExampleInput{ + Id: &id, + } + + output, err := conn.GetExample(ctx, input) + if err != nil { + var notFound *types.ResourceNotFoundException + if errors.As(err, ¬Found) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + return nil, err + } + + if output == nil || output.Example == nil { + return nil, tfresource.NewEmptyResultError(input) + } + + return output.Example, nil +} +``` + +### Waiting for Resource States + +```go +func waitExampleCreated(ctx context.Context, conn *example.Client, id string, timeout time.Duration) (*example.Example, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{"CREATING", "PENDING"}, + Target: []string{"ACTIVE", "AVAILABLE"}, + Refresh: statusExample(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if output, ok := outputRaw.(*example.Example); ok { + return output, err + } + + return nil, err +} + +func statusExample(ctx context.Context, conn *example.Client, id string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + output, err := findExampleByID(ctx, conn, id) + if tfresource.NotFound(err) { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + return output, string(output.Status), nil + } +} +``` + +## Error Handling + +```go +// Map specific API error types +var notFound *types.ResourceNotFoundException +if errors.As(err, ¬Found) { + // Resource doesn't exist +} + +var conflict *types.ConflictException +if errors.As(err, &conflict) { + // Resource state conflict +} +``` + +### Diagnostics + +```go +resp.Diagnostics.AddError( + "Error creating Example", + fmt.Sprintf("Could not create example: %s", err), +) + +resp.Diagnostics.AddWarning( + "Resource modified outside Terraform", + "Resource was modified outside of Terraform; state may be inconsistent", +) + +resp.Diagnostics.AddAttributeError( + path.Root("name"), + "Invalid name", + "Name must be lowercase alphanumeric", +) +``` + +## Testing + +### Basic Acceptance Test + +```go +func TestAccExampleResource_basic(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "provider_example.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckExampleDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccExampleConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckExampleExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttrSet(resourceName, "arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} +``` + +### Disappears Test + +```go +func TestAccExampleResource_disappears(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "provider_example.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckExampleDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccExampleConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckExampleExists(ctx, resourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, ResourceExample(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} +``` + +### Test Helper Functions + +```go +func testAccCheckExampleExists(ctx context.Context, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("Not found: %s", name) + } + + conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx) + _, err := findExampleByID(ctx, conn, rs.Primary.ID) + + return err + } +} + +func testAccCheckExampleDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "provider_example" { + continue + } + + _, err := findExampleByID(ctx, conn, rs.Primary.ID) + if tfresource.NotFound(err) { + continue + } + if err != nil { + return err + } + + return fmt.Errorf("Example %s still exists", rs.Primary.ID) + } + + return nil + } +} +``` + +### Running Tests + +```bash +# Compile tests +go test -c -o /dev/null ./internal/service/ + +# Run acceptance tests +TF_ACC=1 go test ./internal/service/ -run TestAccExample -v -timeout 60m + +# Run sweeper to clean up +TF_ACC=1 go test ./internal/service/ -sweep= -v +``` + +## Documentation Standards + +```markdown +--- +subcategory: "Service Name" +layout: "provider" +page_title: "Provider: provider_example" +description: |- + Manages an Example resource. +--- + +# Resource: provider_example + +Manages an Example resource. + +## Example Usage + +### Basic Usage + +\```hcl +resource "provider_example" "example" { + name = "my-example" +} +\``` + +## Argument Reference + +* `name` - (Required) Name of the example. +* `description` - (Optional) Description of the example. + +## Attribute Reference + +* `id` - ID of the example. +* `arn` - ARN of the example. + +## Import + +Example can be imported using the ID: + +\``` +$ terraform import provider_example.example example-id-12345 +\``` +``` + +## Pre-Submission Checklist + +- [ ] Code compiles: `go build -o /dev/null .` +- [ ] Tests compile: `go test -c -o /dev/null ./internal/service/` +- [ ] All CRUD operations implemented +- [ ] Import is implemented and tested +- [ ] Disappears test is included +- [ ] Documentation is complete with examples +- [ ] Error messages are clear and include resource identifiers +- [ ] Sensitive attributes are marked +- [ ] Plan modifiers are appropriate for each attribute +- [ ] Validators cover relevant edge cases + +## References + +- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework) +- [Acceptance Testing](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests) +- [terraform-plugin-framework GitHub](https://github.com/hashicorp/terraform-plugin-framework) diff --git a/terraform/provider-development/skills/provider-resources-with-sdk/SKILL.md b/terraform/provider-development/skills/provider-resources-with-sdk/SKILL.md new file mode 100644 index 0000000..b6f85ea --- /dev/null +++ b/terraform/provider-development/skills/provider-resources-with-sdk/SKILL.md @@ -0,0 +1,530 @@ +--- +name: provider-development-with-sdk +description: Implement Terraform Provider resources and data sources using the Terraform Plugin SDKv2. Use for SDKv2-only providers, or when maintaining existing SDKv2 resources in a combined mux provider. +metadata: + copyright: Copyright IBM Corp. 2026 + version: "0.0.1" +--- + +# Terraform Provider Resources — Plugin SDKv2 + +## Overview + +This guide covers developing Terraform Provider resources and data sources using the [Terraform Plugin SDKv2](https://developer.hashicorp.com/terraform/plugin/sdkv2). Resources represent infrastructure objects that Terraform manages through Create, Read, Update, and Delete (CRUD) operations. + +**References:** +- [Terraform Plugin SDKv2](https://developer.hashicorp.com/terraform/plugin/sdkv2) +- [Resource Development](https://developer.hashicorp.com/terraform/plugin/sdkv2/resources) +- [Data Source Development](https://developer.hashicorp.com/terraform/plugin/sdkv2/data-sources) + +## File Structure + +Resources follow the standard service package structure: + +``` +internal/service// +├── .go # Resource implementation +├── _test.go # Acceptance tests +├── _data_source.go # Data source (if applicable) +├── find.go # Finder functions +├── exports_test.go # Test exports +└── service_package_gen.go # Auto-generated registration +``` + +Documentation structure: +``` +website/docs/r/ +└── _.html.markdown # Resource documentation + +website/docs/d/ +└── _.html.markdown # Data source documentation +``` + +## Resource Structure + +```go +func ResourceExample() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceExampleCreate, + ReadWithoutTimeout: resourceExampleRead, + UpdateWithoutTimeout: resourceExampleUpdate, + DeleteWithoutTimeout: resourceExampleDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Update: schema.DefaultTimeout(30 * time.Minute), + Delete: schema.DefaultTimeout(30 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 255), + }, + "description": { + Type: schema.TypeString, + Optional: true, + }, + "arn": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} +``` + +## CRUD Operations + +### Create + +```go +func resourceExampleCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.Client).ExampleClient(ctx) + + name := d.Get("name").(string) + input := &example.CreateExampleInput{ + Name: name, + } + + output, err := conn.CreateExample(ctx, input) + if err != nil { + return diag.Errorf("creating Example (%s): %s", name, err) + } + + d.SetId(output.Id) + + if _, err := waitExampleCreated(ctx, conn, d.Id(), d.Timeout(schema.TimeoutCreate)); err != nil { + return diag.Errorf("waiting for Example (%s) creation: %s", d.Id(), err) + } + + return resourceExampleRead(ctx, d, meta) +} +``` + +### Read + +```go +func resourceExampleRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.Client).ExampleClient(ctx) + + output, err := findExampleByID(ctx, conn, d.Id()) + if tfresource.NotFound(err) { + log.Printf("[WARN] Example (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + if err != nil { + return diag.Errorf("reading Example (%s): %s", d.Id(), err) + } + + d.Set("name", output.Name) + d.Set("description", output.Description) + d.Set("arn", output.Arn) + + return nil +} +``` + +### Update + +```go +func resourceExampleUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.Client).ExampleClient(ctx) + + if d.HasChanges("description") { + input := &example.UpdateExampleInput{ + Id: d.Id(), + Description: d.Get("description").(string), + } + + _, err := conn.UpdateExample(ctx, input) + if err != nil { + return diag.Errorf("updating Example (%s): %s", d.Id(), err) + } + } + + return resourceExampleRead(ctx, d, meta) +} +``` + +### Delete + +```go +func resourceExampleDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.Client).ExampleClient(ctx) + + log.Printf("[DEBUG] Deleting Example: %s", d.Id()) + + _, err := conn.DeleteExample(ctx, &example.DeleteExampleInput{ + Id: d.Id(), + }) + + if tfresource.NotFound(err) { + return nil + } + + if err != nil { + return diag.Errorf("deleting Example (%s): %s", d.Id(), err) + } + + if _, err := waitExampleDeleted(ctx, conn, d.Id(), d.Timeout(schema.TimeoutDelete)); err != nil { + return diag.Errorf("waiting for Example (%s) deletion: %s", d.Id(), err) + } + + return nil +} +``` + +## Schema Design + +### Attribute Types + +| Terraform Type | SDKv2 Type | Use Case | +|---|---|---| +| `string` | `schema.TypeString` | Names, IDs, identifiers | +| `int` | `schema.TypeInt` | Counts, ports | +| `float` | `schema.TypeFloat` | Numeric values | +| `bool` | `schema.TypeBool` | Feature flags | +| `list` | `schema.TypeList` | Ordered collections | +| `set` | `schema.TypeSet` | Unordered unique items | +| `map` | `schema.TypeMap` | Key-value pairs | + +### Nested Objects + +Use `schema.TypeList` with `MaxItems: 1` for single nested objects: + +```go +"config": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "timeout": { + Type: schema.TypeInt, + Optional: true, + Default: 30, + ValidateFunc: validation.IntBetween(1, 3600), + }, + }, + }, +}, +``` + +### Sensitive Attributes + +```go +"password": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + ValidateFunc: validation.StringLenBetween(8, 128), +}, +``` + +### ForceNew + +Use `ForceNew: true` on attributes where an in-place update is not possible: + +```go +"name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, +}, +``` + +## State Management + +### Handling Resource Not Found + +```go +func findExampleByID(ctx context.Context, conn *example.Client, id string) (*example.Example, error) { + input := &example.GetExampleInput{ + Id: id, + } + + output, err := conn.GetExample(ctx, input) + + if err != nil { + var notFound *types.ResourceNotFoundException + if errors.As(err, ¬Found) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + return nil, err + } + + if output == nil || output.Example == nil { + return nil, tfresource.NewEmptyResultError(input) + } + + return output.Example, nil +} +``` + +### Waiting for Resource States + +```go +func waitExampleCreated(ctx context.Context, conn *example.Client, id string, timeout time.Duration) (*example.Example, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{"CREATING", "PENDING"}, + Target: []string{"ACTIVE", "AVAILABLE"}, + Refresh: statusExample(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if output, ok := outputRaw.(*example.Example); ok { + return output, err + } + + return nil, err +} + +func waitExampleDeleted(ctx context.Context, conn *example.Client, id string, timeout time.Duration) (*example.Example, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{"DELETING"}, + Target: []string{}, + Refresh: statusExample(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if output, ok := outputRaw.(*example.Example); ok { + return output, err + } + + return nil, err +} + +func statusExample(ctx context.Context, conn *example.Client, id string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + output, err := findExampleByID(ctx, conn, id) + if tfresource.NotFound(err) { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + return output, output.Status, nil + } +} +``` + +## Error Handling + +```go +// Match specific API error types +var notFound *types.ResourceNotFoundException +if errors.As(err, ¬Found) { + // Resource doesn't exist +} + +var conflict *types.ConflictException +if errors.As(err, &conflict) { + // Resource state conflict +} +``` + +Return errors as diagnostics: + +```go +return diag.Errorf("creating Example (%s): %s", name, err) +``` + +## Testing + +### Basic Acceptance Test + +Use `ProviderFactories` for SDKv2-only providers. For mux providers, use `ProtoV5ProviderFactories` even when testing SDKv2 resources, since the provider binary exposes a Protocol v5 server. + +```go +func TestAccExampleResource_basic(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "provider_example.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, // use ProviderFactories for SDKv2-only providers + CheckDestroy: testAccCheckExampleDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccExampleConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckExampleExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttrSet(resourceName, "arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} +``` + +### Disappears Test + +```go +func TestAccExampleResource_disappears(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "provider_example.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckExampleDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccExampleConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckExampleExists(ctx, resourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, ResourceExample(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} +``` + +### Test Helper Functions + +```go +func testAccCheckExampleExists(ctx context.Context, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("Not found: %s", name) + } + + conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx) + _, err := findExampleByID(ctx, conn, rs.Primary.ID) + + return err + } +} + +func testAccCheckExampleDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "provider_example" { + continue + } + + _, err := findExampleByID(ctx, conn, rs.Primary.ID) + if tfresource.NotFound(err) { + continue + } + if err != nil { + return err + } + + return fmt.Errorf("Example %s still exists", rs.Primary.ID) + } + + return nil + } +} +``` + +### Running Tests + +```bash +# Compile tests +go test -c -o /dev/null ./internal/service/ + +# Run acceptance tests +TF_ACC=1 go test ./internal/service/ -run TestAccExample -v -timeout 60m + +# Run sweeper to clean up +TF_ACC=1 go test ./internal/service/ -sweep= -v +``` + +## Documentation Standards + +```markdown +--- +subcategory: "Service Name" +layout: "provider" +page_title: "Provider: provider_example" +description: |- + Manages an Example resource. +--- + +# Resource: provider_example + +Manages an Example resource. + +## Example Usage + +### Basic Usage + +\```hcl +resource "provider_example" "example" { + name = "my-example" +} +\``` + +## Argument Reference + +* `name` - (Required) Name of the example. Forces a new resource. +* `description` - (Optional) Description of the example. + +## Attribute Reference + +* `id` - ID of the example. +* `arn` - ARN of the example. + +## Timeouts + +`provider_example` provides the following Timeouts configuration block: + +* `create` - (Default `30 minutes`) Used when creating the example. +* `update` - (Default `30 minutes`) Used when updating the example. +* `delete` - (Default `30 minutes`) Used when deleting the example. + +## Import + +Example can be imported using the ID: + +\``` +$ terraform import provider_example.example example-id-12345 +\``` +``` + +## Pre-Submission Checklist + +- [ ] Code compiles: `go build -o /dev/null .` +- [ ] Tests compile: `go test -c -o /dev/null ./internal/service/` +- [ ] All CRUD operations implemented +- [ ] `ForceNew` set on immutable attributes +- [ ] Import is implemented and tested +- [ ] Disappears test is included +- [ ] `Timeouts` block defined and used in waiter calls +- [ ] Documentation is complete with examples and Timeouts section +- [ ] Error messages are clear and include resource identifiers +- [ ] Sensitive attributes are marked + +## References + +- [Terraform Plugin SDKv2](https://developer.hashicorp.com/terraform/plugin/sdkv2) +- [Acceptance Testing](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests) +- [terraform-plugin-sdk GitHub](https://github.com/hashicorp/terraform-plugin-sdk) diff --git a/terraform/provider-development/skills/provider-resources/SKILL.md b/terraform/provider-development/skills/provider-resources/SKILL.md index 7d93de3..92adf92 100644 --- a/terraform/provider-development/skills/provider-resources/SKILL.md +++ b/terraform/provider-development/skills/provider-resources/SKILL.md @@ -1,599 +1,38 @@ --- name: provider-resources -description: Implement Terraform Provider resources and data sources using the Plugin Framework. Use when developing CRUD operations, schema design, state management, and acceptance testing for provider resources. +description: Detect the Terraform provider SDK type and load the correct resource implementation guidance. Use when developing resources or data sources for any Terraform provider — Framework, SDKv2, or a combined mux provider. metadata: copyright: Copyright IBM Corp. 2026 version: "0.0.1" --- -# Terraform Provider Resources Implementation Guide +# Terraform Provider Resources -## Overview +Before implementing resources or data sources, determine which SDK the provider uses by reading its `go.mod` file. -This guide covers developing Terraform Provider resources and data sources using the Terraform Plugin Framework. Resources represent infrastructure objects that Terraform manages through Create, Read, Update, and Delete (CRUD) operations. +## Provider Type Detection -**References:** -- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework) -- [Resource Development](https://developer.hashicorp.com/terraform/plugin/framework/resources) -- [Data Source Development](https://developer.hashicorp.com/terraform/plugin/framework/data-sources) +Inspect the `require` block in `go.mod` for these module paths: -## File Structure +| If `go.mod` contains | Provider type | +|---|---| +| `github.com/hashicorp/terraform-plugin-mux` | **Combined** — Framework + SDKv2, bridged with mux | +| `github.com/hashicorp/terraform-plugin-framework` (without mux) | **Framework-only** | +| Neither of the above | **SDKv2-only** | -Resources follow the standard service package structure: +## Guidance by Provider Type -``` -internal/service// -├── .go # Resource implementation -├── _test.go # Acceptance tests -├── _data_source.go # Data source (if applicable) -├── find.go # Finder functions -├── exports_test.go # Test exports -└── service_package_gen.go # Auto-generated registration -``` +### Combined Provider (`terraform-plugin-mux` present) -Documentation structure: -``` -website/docs/r/ -└── _.html.markdown # Resource documentation +The provider bridges SDKv2 and Plugin Framework using `terraform-plugin-mux`. Both SDKs coexist in the same binary. -website/docs/d/ -└── _.html.markdown # Data source documentation -``` +- **Existing resources** — Almost always SDKv2. To confirm, check whether the resource registration function returns `*schema.Resource` (SDKv2) or implements the `resource.Resource` interface (Framework). Apply the `provider-development-with-sdk` skill. +- **New resources** — Author using Plugin Framework. Apply the `provider-development-with-framework` skill. -## Resource Structure +### Framework-only provider (`terraform-plugin-framework` present, no mux) -### SDKv2 Resource Pattern +All resources use Plugin Framework. Apply the `provider-development-with-framework` skill. -```go -func ResourceExample() *schema.Resource { - return &schema.Resource{ - CreateWithoutTimeout: resourceExampleCreate, - ReadWithoutTimeout: resourceExampleRead, - UpdateWithoutTimeout: resourceExampleUpdate, - DeleteWithoutTimeout: resourceExampleDelete, +### SDKv2-only provider (neither dependency present) - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - ValidateFunc: validation.StringLenBetween(1, 255), - }, - "arn": { - Type: schema.TypeString, - Computed: true, - }, - "tags": tftags.TagsSchema(), - "tags_all": tftags.TagsSchemaComputed(), - }, - - CustomizeDiff: verify.SetTagsDiff, - } -} -``` - -### Plugin Framework Resource Pattern - -```go -type resourceExample struct { - framework.ResourceWithConfigure -} - -func (r *resourceExample) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_example" -} - -func (r *resourceExample) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - "id": framework.IDAttribute(), - "name": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - Validators: []validator.String{ - stringvalidator.LengthBetween(1, 255), - }, - }, - "arn": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - }, - } -} -``` - -## CRUD Operations - -### Create Operation - -```go -func (r *resourceExample) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data resourceExampleModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - conn := r.Meta().ExampleClient(ctx) - - input := &example.CreateExampleInput{ - Name: data.Name.ValueStringPointer(), - } - - output, err := conn.CreateExample(ctx, input) - if err != nil { - resp.Diagnostics.AddError( - "Error creating Example", - fmt.Sprintf("Could not create example %s: %s", data.Name.ValueString(), err), - ) - return - } - - data.ID = types.StringPointerValue(output.Id) - data.ARN = types.StringPointerValue(output.Arn) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} -``` - -### Read Operation - -```go -func (r *resourceExample) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data resourceExampleModel - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - conn := r.Meta().ExampleClient(ctx) - - output, err := findExampleByID(ctx, conn, data.ID.ValueString()) - if tfresource.NotFound(err) { - resp.Diagnostics.AddWarning( - "Resource not found", - fmt.Sprintf("Example %s not found, removing from state", data.ID.ValueString()), - ) - resp.State.RemoveResource(ctx) - return - } - if err != nil { - resp.Diagnostics.AddError( - "Error reading Example", - fmt.Sprintf("Could not read example %s: %s", data.ID.ValueString(), err), - ) - return - } - - data.Name = types.StringPointerValue(output.Name) - data.ARN = types.StringPointerValue(output.Arn) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} -``` - -### Update Operation - -```go -func (r *resourceExample) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan, state resourceExampleModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - - conn := r.Meta().ExampleClient(ctx) - - if !plan.Description.Equal(state.Description) { - input := &example.UpdateExampleInput{ - Id: plan.ID.ValueStringPointer(), - Description: plan.Description.ValueStringPointer(), - } - - _, err := conn.UpdateExample(ctx, input) - if err != nil { - resp.Diagnostics.AddError( - "Error updating Example", - fmt.Sprintf("Could not update example %s: %s", plan.ID.ValueString(), err), - ) - return - } - } - - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) -} -``` - -### Delete Operation - -```go -func (r *resourceExample) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data resourceExampleModel - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - conn := r.Meta().ExampleClient(ctx) - - _, err := conn.DeleteExample(ctx, &example.DeleteExampleInput{ - Id: data.ID.ValueStringPointer(), - }) - - if tfresource.NotFound(err) { - return - } - - if err != nil { - resp.Diagnostics.AddError( - "Error deleting Example", - fmt.Sprintf("Could not delete example %s: %s", data.ID.ValueString(), err), - ) - return - } -} -``` - -## Schema Design - -### Attribute Types - -| Terraform Type | Framework Type | Use Case | -|----------------|----------------|----------| -| `string` | `schema.StringAttribute` | Names, ARNs, IDs | -| `number` | `schema.Int64Attribute`, `schema.Float64Attribute` | Counts, sizes | -| `bool` | `schema.BoolAttribute` | Feature flags | -| `list` | `schema.ListAttribute` | Ordered collections | -| `set` | `schema.SetAttribute` | Unordered unique items | -| `map` | `schema.MapAttribute` | Key-value pairs | -| `object` | `schema.SingleNestedAttribute` | Complex nested config | - -### Plan Modifiers - -```go -// Force replacement when value changes -stringplanmodifier.RequiresReplace() - -// Preserve unknown value during plan -stringplanmodifier.UseStateForUnknown() - -// Custom plan modifier -stringplanmodifier.RequiresReplaceIf( - func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { - // Custom logic - }, - "description", - "markdown description", -) -``` - -### Validators - -```go -// String validators -stringvalidator.LengthBetween(1, 255) -stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z0-9-]+$`), "must be lowercase alphanumeric with hyphens") -stringvalidator.OneOf("option1", "option2", "option3") - -// Int64 validators -int64validator.Between(1, 100) -int64validator.AtLeast(1) -int64validator.AtMost(1000) - -// List validators -listvalidator.SizeAtLeast(1) -listvalidator.SizeAtMost(10) -``` - -### Sensitive Attributes - -```go -"password": schema.StringAttribute{ - Required: true, - Sensitive: true, - Validators: []validator.String{ - stringvalidator.LengthAtLeast(8), - }, -} -``` - -## State Management - -### Handling Resource Not Found - -```go -func findExampleByID(ctx context.Context, conn *example.Client, id string) (*example.Example, error) { - input := &example.GetExampleInput{ - Id: &id, - } - - output, err := conn.GetExample(ctx, input) - if err != nil { - var notFound *types.ResourceNotFoundException - if errors.As(err, ¬Found) { - return nil, &retry.NotFoundError{ - LastError: err, - LastRequest: input, - } - } - return nil, err - } - - if output == nil || output.Example == nil { - return nil, tfresource.NewEmptyResultError(input) - } - - return output.Example, nil -} -``` - -### Waiting for Resource States - -```go -func waitExampleCreated(ctx context.Context, conn *example.Client, id string, timeout time.Duration) (*example.Example, error) { - stateConf := &retry.StateChangeConf{ - Pending: []string{"CREATING", "PENDING"}, - Target: []string{"ACTIVE", "AVAILABLE"}, - Refresh: statusExample(ctx, conn, id), - Timeout: timeout, - } - - outputRaw, err := stateConf.WaitForStateContext(ctx) - if output, ok := outputRaw.(*example.Example); ok { - return output, err - } - - return nil, err -} - -func statusExample(ctx context.Context, conn *example.Client, id string) retry.StateRefreshFunc { - return func() (interface{}, string, error) { - output, err := findExampleByID(ctx, conn, id) - if tfresource.NotFound(err) { - return nil, "", nil - } - if err != nil { - return nil, "", err - } - return output, string(output.Status), nil - } -} -``` - -## Testing - -### Basic Acceptance Test - -```go -func TestAccExampleResource_basic(t *testing.T) { - ctx := acctest.Context(t) - rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) - resourceName := "provider_example.test" - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(ctx, t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, - CheckDestroy: testAccCheckExampleDestroy(ctx), - Steps: []resource.TestStep{ - { - Config: testAccExampleConfig_basic(rName), - Check: resource.ComposeTestCheckFunc( - testAccCheckExampleExists(ctx, resourceName), - resource.TestCheckResourceAttr(resourceName, "name", rName), - resource.TestCheckResourceAttrSet(resourceName, "arn"), - ), - }, - { - ResourceName: resourceName, - ImportState: true, - ImportStateVerify: true, - }, - }, - }) -} -``` - -### Disappears Test - -```go -func TestAccExampleResource_disappears(t *testing.T) { - ctx := acctest.Context(t) - rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) - resourceName := "provider_example.test" - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(ctx, t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, - CheckDestroy: testAccCheckExampleDestroy(ctx), - Steps: []resource.TestStep{ - { - Config: testAccExampleConfig_basic(rName), - Check: resource.ComposeTestCheckFunc( - testAccCheckExampleExists(ctx, resourceName), - acctest.CheckResourceDisappears(ctx, acctest.Provider, ResourceExample(), resourceName), - ), - ExpectNonEmptyPlan: true, - }, - }, - }) -} -``` - -### Test Helper Functions - -```go -func testAccCheckExampleExists(ctx context.Context, name string) resource.TestCheckFunc { - return func(s *terraform.State) error { - rs, ok := s.RootModule().Resources[name] - if !ok { - return fmt.Errorf("Not found: %s", name) - } - - conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx) - _, err := findExampleByID(ctx, conn, rs.Primary.ID) - - return err - } -} - -func testAccCheckExampleDestroy(ctx context.Context) resource.TestCheckFunc { - return func(s *terraform.State) error { - conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx) - - for _, rs := range s.RootModule().Resources { - if rs.Type != "provider_example" { - continue - } - - _, err := findExampleByID(ctx, conn, rs.Primary.ID) - if tfresource.NotFound(err) { - continue - } - if err != nil { - return err - } - - return fmt.Errorf("Example %s still exists", rs.Primary.ID) - } - - return nil - } -} -``` - -### Running Tests - -```bash -# Compile tests -go test -c -o /dev/null ./internal/service/ - -# Run acceptance tests -TF_ACC=1 go test ./internal/service/ -run TestAccExample -v -timeout 60m - -# Run with specific provider version -TF_ACC=1 go test ./internal/service/ -run TestAccExample -v - -# Run sweeper to clean up -TF_ACC=1 go test ./internal/service/ -sweep= -v -``` - -## Error Handling - -### Common Error Patterns - -```go -// Handle specific API errors -var notFound *types.ResourceNotFoundException -if errors.As(err, ¬Found) { - // Resource doesn't exist -} - -var conflict *types.ConflictException -if errors.As(err, &conflict) { - // Resource state conflict -} - -var throttle *types.ThrottlingException -if errors.As(err, &throttle) { - // Rate limited - SDK handles retry -} -``` - -### Diagnostics - -```go -// Add error -resp.Diagnostics.AddError( - "Error creating resource", - fmt.Sprintf("Could not create resource: %s", err), -) - -// Add warning -resp.Diagnostics.AddWarning( - "Resource modified outside Terraform", - "Resource was modified outside of Terraform, state may be inconsistent", -) - -// Add attribute error -resp.Diagnostics.AddAttributeError( - path.Root("name"), - "Invalid name", - "Name must be lowercase alphanumeric", -) -``` - -## Documentation Standards - -### Resource Documentation - -```markdown ---- -subcategory: "Service Name" -layout: "provider" -page_title: "Provider: provider_example" -description: |- - Manages an Example resource. ---- - -# Resource: provider_example - -Manages an Example resource. - -## Example Usage - -### Basic Usage - -\```hcl -resource "provider_example" "example" { - name = "my-example" -} -\``` - -## Argument Reference - -* `name` - (Required) Name of the example. -* `description` - (Optional) Description of the example. - -## Attribute Reference - -* `id` - ID of the example. -* `arn` - ARN of the example. - -## Import - -Example can be imported using the ID: - -\``` -$ terraform import provider_example.example example-id-12345 -\``` -``` - -## Pre-Submission Checklist - -- [ ] Code compiles without errors -- [ ] All tests pass locally -- [ ] Resource has all CRUD operations implemented -- [ ] Import is implemented and tested -- [ ] Disappears test is included -- [ ] Documentation is complete with examples -- [ ] Error messages are clear and actionable -- [ ] Sensitive attributes are marked -- [ ] Plan modifiers are appropriate -- [ ] Validators cover edge cases - -## References - -- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework) -- [Terraform Plugin SDKv2](https://developer.hashicorp.com/terraform/plugin/sdkv2) -- [Acceptance Testing](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests) -- [terraform-plugin-framework GitHub](https://github.com/hashicorp/terraform-plugin-framework) +All resources use SDKv2. Apply the `provider-development-with-sdk` skill.