diff --git a/terraform/provider-development/skills/provider-resources/SKILL.md b/terraform/provider-development/skills/provider-resources/SKILL.md index 7d93de3..67b5a34 100644 --- a/terraform/provider-development/skills/provider-resources/SKILL.md +++ b/terraform/provider-development/skills/provider-resources/SKILL.md @@ -1,6 +1,15 @@ --- 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: >- + Implement Terraform Provider resources and data sources using the Plugin + Framework: CRUD operations, schema design, plan modifiers and validators, + not-found handling, waiters for eventually consistent APIs, import support, + resource design principles, and required acceptance test coverage. Use when + adding or changing a resource or data source, deciding whether an API + concept should be a resource, wiring a resource to the provider's + configured client, handling drift or resource-not-found, or reviewing a + resource implementation before submission. +license: MPL-2.0 metadata: copyright: Copyright IBM Corp. 2026 version: "0.0.1" @@ -10,87 +19,103 @@ metadata: ## Overview -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. - -**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) +This guide covers developing Terraform Provider resources and data sources. +Resources represent infrastructure objects that Terraform manages through +Create, Read, Update, and Delete (CRUD) operations. + +**Use the [Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework) +for all net-new resources and data sources.** Plugin SDKv2 is for maintaining +resources that already exist on it; do not write new code against it. A +provider can serve both during migration by muxing +([terraform-plugin-mux](https://developer.hashicorp.com/terraform/plugin/mux)), +so adopting the Framework never requires a big-bang rewrite. To tell which +mode an existing provider is in, check `go.mod`: `terraform-plugin-mux` +present means it serves both SDKv2 and Framework code; only +`terraform-plugin-sdk/v2` means SDKv2-only; only +`terraform-plugin-framework` means Framework-only. Be cautious about +*migrating* existing SDKv2 resources: the Framework distinguishes null from +zero values, so naive migrations change behavior for existing users (use the +`provider-framework-migration` skill, if available). + +**References** (load when needed): +- `references/design-principles.md` — what should (and should not) become a + resource; data source semantics; relationship and async-task modeling +- `references/retries-and-waiters.md` — eventual consistency, retry + patterns, and status/wait function structure ## File Structure -Resources follow the standard service package structure: +Most providers keep every resource in a single package: ``` -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 +internal/provider/ +├── provider.go # Provider schema + Configure +├── widget_resource.go # Resource implementation +├── widget_resource_test.go # Acceptance tests +├── widget_data_source.go # Data source (if applicable) +└── widget_data_source_test.go ``` -Documentation structure: -``` -website/docs/r/ -└── _.html.markdown # Resource documentation +Large multi-service providers (e.g. terraform-provider-aws) split into +`internal/service//` packages instead, with an idiomatic file +taxonomy worth adopting once a package grows: `consts.go`, `find.go` +(finders), `status.go` (status functions), `wait.go` (waiters), `sweep.go` +(test sweepers), `exports_test.go`. + +Documentation lives in `docs/` and is generated with `tfplugindocs`: -website/docs/d/ -└── _.html.markdown # Data source documentation ``` +docs/ +├── resources/.md # generated; optional .md.tmpl template +└── data-sources/.md +``` + +(Hand-written `website/docs/r/*.html.markdown` trees exist in some older, +large providers — follow the target repo's convention when editing one.) ## Resource Structure -### SDKv2 Resource Pattern +A Framework resource is a struct holding the API client, with interface +assertions making the implemented behaviors explicit: ```go -func ResourceExample() *schema.Resource { - return &schema.Resource{ - CreateWithoutTimeout: resourceExampleCreate, - ReadWithoutTimeout: resourceExampleRead, - UpdateWithoutTimeout: resourceExampleUpdate, - DeleteWithoutTimeout: resourceExampleDelete, - - 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(), - }, +var ( + _ resource.Resource = &widgetResource{} + _ resource.ResourceWithConfigure = &widgetResource{} + _ resource.ResourceWithImportState = &widgetResource{} +) - CustomizeDiff: verify.SetTagsDiff, - } +func NewWidgetResource() resource.Resource { + return &widgetResource{} } -``` -### Plugin Framework Resource Pattern +type widgetResource struct { + client *examplecloud.Client +} -```go -type resourceExample struct { - framework.ResourceWithConfigure +func (r *widgetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_widget" } -func (r *resourceExample) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_example" +// Configure receives the client the provider built in its own Configure. +func (r *widgetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return // provider not yet configured (e.g. validation phase) + } + client, ok := req.ProviderData.(*examplecloud.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *examplecloud.Client, got: %T.", req.ProviderData), + ) + return + } + r.client = client } -func (r *resourceExample) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *widgetResource) 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{ @@ -100,7 +125,7 @@ func (r *resourceExample) Schema(ctx context.Context, req resource.SchemaRequest stringvalidator.LengthBetween(1, 255), }, }, - "arn": schema.StringAttribute{ + "id": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), @@ -111,100 +136,105 @@ func (r *resourceExample) Schema(ctx context.Context, req resource.SchemaRequest } ``` +How the provider's `Configure` produces that client — schema, credential +resolution, validation — is covered by the `provider-configuration` skill +(if available). + +**On `id`:** SDKv2 required a magic `id` attribute; the Framework does not. +If the API has its own identifier, expose it under its real meaning and do +not add a second, redundant `id`. Only keep `id` when it *is* the API's +identifier (as above). + ## CRUD Operations -### Create Operation +### Create ```go -func (r *resourceExample) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data resourceExampleModel +func (r *widgetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data widgetResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - conn := r.Meta().ExampleClient(ctx) - - input := &example.CreateExampleInput{ + input := &examplecloud.CreateWidgetInput{ Name: data.Name.ValueStringPointer(), } - output, err := conn.CreateExample(ctx, input) + output, err := r.client.CreateWidget(ctx, input) if err != nil { resp.Diagnostics.AddError( - "Error creating Example", - fmt.Sprintf("Could not create example %s: %s", data.Name.ValueString(), err), + "Error creating Widget", + fmt.Sprintf("creating Widget (%s): %s", data.Name.ValueString(), err), ) return } - data.ID = types.StringPointerValue(output.Id) - data.ARN = types.StringPointerValue(output.Arn) + data.ID = types.StringPointerValue(output.ID) + + // For eventually consistent APIs, wait for the resource to be usable + // before returning — see references/retries-and-waiters.md. resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } ``` -### Read Operation +### Read + +Read must handle out-of-band deletion by removing the resource from state so +the next plan recreates it, rather than erroring forever: ```go -func (r *resourceExample) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data resourceExampleModel +func (r *widgetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data widgetResourceModel 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()), - ) + output, err := findWidgetByID(ctx, r.client, data.ID.ValueString()) + if isNotFound(err) { + tflog.Warn(ctx, "Widget not found, removing from state", map[string]any{"id": 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), + "Error reading Widget", + fmt.Sprintf("reading Widget (%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 +### Update + +Only call the API for attributes that actually changed; compare plan against +state: ```go -func (r *resourceExample) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan, state resourceExampleModel +func (r *widgetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state widgetResourceModel 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(), + input := &examplecloud.UpdateWidgetInput{ + ID: plan.ID.ValueStringPointer(), Description: plan.Description.ValueStringPointer(), } - - _, err := conn.UpdateExample(ctx, input) - if err != nil { + if _, err := r.client.UpdateWidget(ctx, input); err != nil { resp.Diagnostics.AddError( - "Error updating Example", - fmt.Sprintf("Could not update example %s: %s", plan.ID.ValueString(), err), + "Error updating Widget", + fmt.Sprintf("updating Widget (%s): %s", plan.ID.ValueString(), err), ) return } @@ -214,43 +244,73 @@ func (r *resourceExample) Update(ctx context.Context, req resource.UpdateRequest } ``` -### Delete Operation +### Delete + +Treat "already gone" as success — the desired end state is reached: ```go -func (r *resourceExample) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data resourceExampleModel +func (r *widgetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data widgetResourceModel 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(), + _, err := r.client.DeleteWidget(ctx, &examplecloud.DeleteWidgetInput{ + ID: data.ID.ValueStringPointer(), }) - - if tfresource.NotFound(err) { + if isNotFound(err) { return } - if err != nil { resp.Diagnostics.AddError( - "Error deleting Example", - fmt.Sprintf("Could not delete example %s: %s", data.ID.ValueString(), err), + "Error deleting Widget", + fmt.Sprintf("deleting Widget (%s): %s", data.ID.ValueString(), err), ) return } } ``` +### Import + +With `ResourceWithImportState` asserted, passthrough of the identifier is +one line: + +```go +func (r *widgetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} +``` + +For multi-part identifiers, parse a delimited import ID (commonly +comma-separated) and set each attribute explicitly. + +## Resource Design Principles + +Before implementing, check the shape of the thing being modeled (full +treatment in `references/design-principles.md`): + +- A resource is the *smallest* useful building block; if the API offers + CRUD for it, it likely deserves its own resource. +- A resource should talk to **one** API/service only — cross-service + resources break permissions, auditing, and endpoint configuration. +- Data sources are read-only and side-effect free. A *singular* data source + errors on zero or multiple matches; a *plural* data source (plural noun + name) returns zero-or-more as a collection and errors on neither. +- Attached policies/rules, long-running task invocations, and versioned + artifacts usually deserve their *own* resources rather than attributes on + the parent. +- Start/stop or enable/disable state belongs as an attribute *in* the + resource, not as a separate resource. + ## Schema Design ### Attribute Types | Terraform Type | Framework Type | Use Case | |----------------|----------------|----------| -| `string` | `schema.StringAttribute` | Names, ARNs, IDs | +| `string` | `schema.StringAttribute` | Names, identifiers | | `number` | `schema.Int64Attribute`, `schema.Float64Attribute` | Counts, sizes | | `bool` | `schema.BoolAttribute` | Feature flags | | `list` | `schema.ListAttribute` | Ordered collections | @@ -258,41 +318,27 @@ func (r *resourceExample) Delete(ctx context.Context, req resource.DeleteRequest | `map` | `schema.MapAttribute` | Key-value pairs | | `object` | `schema.SingleNestedAttribute` | Complex nested config | +Give every attribute a `MarkdownDescription` — `tfplugindocs` publishes it, +and it is the primary user-facing documentation. + ### Plan Modifiers ```go // Force replacement when value changes stringplanmodifier.RequiresReplace() -// Preserve unknown value during plan +// Keep a known value during plan instead of (known after apply) 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 +stringvalidator.OneOf("small", "medium", "large") int64validator.Between(1, 100) -int64validator.AtLeast(1) -int64validator.AtMost(1000) - -// List validators listvalidator.SizeAtLeast(1) -listvalidator.SizeAtMost(10) ``` ### Sensitive Attributes @@ -301,97 +347,93 @@ listvalidator.SizeAtMost(10) "password": schema.StringAttribute{ Required: true, Sensitive: true, - Validators: []validator.String{ - stringvalidator.LengthAtLeast(8), - }, -} +}, ``` ## State Management -### Handling Resource Not Found +### Finders -```go -func findExampleByID(ctx context.Context, conn *example.Client, id string) (*example.Example, error) { - input := &example.GetExampleInput{ - Id: &id, - } +Centralize "get one thing or a typed not-found" in a finder so Read, Delete, +waiters, and tests all share identical not-found semantics: - output, err := conn.GetExample(ctx, input) +```go +func findWidgetByID(ctx context.Context, client *examplecloud.Client, id string) (*examplecloud.Widget, error) { + output, err := client.GetWidget(ctx, &examplecloud.GetWidgetInput{ID: &id}) if err != nil { - var notFound *types.ResourceNotFoundException - if errors.As(err, ¬Found) { - return nil, &retry.NotFoundError{ - LastError: err, - LastRequest: input, - } + var apiErr *examplecloud.NotFoundError + if errors.As(err, &apiErr) { + return nil, &retry.NotFoundError{LastError: err} } - return nil, err + return nil, fmt.Errorf("getting Widget (%s): %w", id, err) } - - if output == nil || output.Example == nil { - return nil, tfresource.NewEmptyResultError(input) + if output == nil || output.Widget == nil { + return nil, &retry.NotFoundError{Message: "empty result"} } + return output.Widget, nil +} - return output.Example, nil +func isNotFound(err error) bool { + var nfe *retry.NotFoundError + return errors.As(err, &nfe) } ``` ### 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 -} +Many APIs return from Create/Delete before the resource is usable/gone. Use +`retry.StateChangeConf` (from +`github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry` — usable from +Framework providers), with a status function built on the finder and +timeouts in named constants: -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 - } +```go +stateConf := &retry.StateChangeConf{ + Pending: []string{"CREATING", "PENDING"}, + Target: []string{"ACTIVE"}, + Refresh: statusWidget(ctx, r.client, id), // one poll of the finder: (obj, status, err) + Timeout: widgetCreatedTimeout, } +outputRaw, err := stateConf.WaitForStateContext(ctx) ``` +The full status/wait function pairs (create and delete waiters, failure-state +handling, post-create not-found retries, eventual-consistency patterns) are +in `references/retries-and-waiters.md` — read it whenever the API is +asynchronous or eventually consistent. + ## Testing -### Basic Acceptance Test +Every resource ships with, at minimum: + +- **`_basic`** — create with minimal config, assert attributes, then an + import step (`ImportState: true`, `ImportStateVerify: true`) +- **`_disappears`** — delete the object out-of-band mid-test; the next plan + must propose recreation, not error +- **Per-attribute tests** — exercise updates for each non-trivial argument + +Naming grammar: tests `TestAcc{Resource}_{group?}_{description}`, helpers +`testAccCheck{Resource}Exists` / `testAccCheck{Resource}Destroy`, config +functions `testAcc{Resource}Config_{description}`. Keep configs +self-contained, randomize real resource names, and never hardcode +environment-specific values (account IDs, zones, versions). ```go -func TestAccExampleResource_basic(t *testing.T) { - ctx := acctest.Context(t) - rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) - resourceName := "provider_example.test" +func TestAccWidget_basic(t *testing.T) { + rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceName := "examplecloud_widget.test" resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(ctx, t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, - CheckDestroy: testAccCheckExampleDestroy(ctx), + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckWidgetDestroy, Steps: []resource.TestStep{ { - Config: testAccExampleConfig_basic(rName), - Check: resource.ComposeTestCheckFunc( - testAccCheckExampleExists(ctx, resourceName), - resource.TestCheckResourceAttr(resourceName, "name", rName), - resource.TestCheckResourceAttrSet(resourceName, "arn"), - ), + Config: testAccWidgetConfig_basic(rName), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("name"), knownvalue.StringExact(rName)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("id"), knownvalue.NotNull()), + }, }, { ResourceName: resourceName, @@ -401,130 +443,45 @@ func TestAccExampleResource_basic(t *testing.T) { }, }) } -``` - -### 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 testAccWidgetConfig_basic(rName string) string { + return fmt.Sprintf(` +resource "examplecloud_widget" "test" { + name = %[1]q } - -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 - } +`, rName) } ``` -### 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 -``` +Use the `provider-test-patterns` skill (if available) for the full testing +treatment: config helper style (`%[1]q` indexed verbs), statecheck/plancheck, +CompareValue, custom StateCheck implementations for exists/disappears +helpers, sweepers, and ephemeral resource testing. Use the +`run-acceptance-tests` skill for executing and debugging test runs. ## Error Handling -### Common Error Patterns +Match API errors by type, not message text, and wrap with context: ```go -// Handle specific API errors -var notFound *types.ResourceNotFoundException +var notFound *examplecloud.NotFoundError if errors.As(err, ¬Found) { - // Resource doesn't exist + // 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 -} +// Wrapping inside helpers: preserve the cause with %w +return fmt.Errorf("creating Widget (%s): %w", name, err) ``` -### Diagnostics +Diagnostics follow a consistent grammar — summary names the operation and +type, detail carries identifier and cause: ```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", + "Error creating Widget", + fmt.Sprintf("creating Widget (%s): %s", name, err), ) -// Add attribute error resp.Diagnostics.AddAttributeError( path.Root("name"), "Invalid name", @@ -532,68 +489,32 @@ resp.Diagnostics.AddAttributeError( ) ``` -## 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 +## Documentation -* `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 -\``` -``` +Write attribute `MarkdownDescription`s first — they are the source of +truth. Then generate Registry documentation with `tfplugindocs` +(`go generate ./...` where wired up), adding `docs/**/*.md.tmpl` templates +only for prose and examples the generator cannot derive. Use the +`provider-docs` skill (if available) for the full documentation workflow and +Registry publication rules. ## Pre-Submission Checklist -- [ ] Code compiles without errors -- [ ] All tests pass locally +- [ ] Plugin Framework used (no new SDKv2 code) - [ ] 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 +- [ ] Read removes missing resources from state; Delete tolerates already-deleted +- [ ] No redundant `id` attribute (real API identifier exposed instead) +- [ ] Import implemented and covered by an `ImportStateVerify` step +- [ ] `_basic`, `_disappears`, and per-attribute tests present +- [ ] Waiters used where the API is eventually consistent +- [ ] Error messages name the operation, type, and identifier +- [ ] Sensitive attributes marked; every attribute has a description +- [ ] Docs generated with `tfplugindocs` +- [ ] Changelog entry added, if the repo tracks release notes (check CONTRIBUTING) ## 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) +- [Resource Development](https://developer.hashicorp.com/terraform/plugin/framework/resources) +- [Data Source Development](https://developer.hashicorp.com/terraform/plugin/framework/data-sources) +- [HashiCorp Provider Design Principles](https://developer.hashicorp.com/terraform/plugin/best-practices/hashicorp-provider-design-principles) diff --git a/terraform/provider-development/skills/provider-resources/references/design-principles.md b/terraform/provider-development/skills/provider-resources/references/design-principles.md new file mode 100644 index 0000000..53ca7e2 --- /dev/null +++ b/terraform/provider-development/skills/provider-resources/references/design-principles.md @@ -0,0 +1,104 @@ +# Resource and Data Source Design Principles + +Distilled from HashiCorp's +[Provider Design Principles](https://developer.hashicorp.com/terraform/plugin/best-practices/hashicorp-provider-design-principles) +and the conventions of large production providers (notably +terraform-provider-aws). Apply these *before* writing code — most painful +provider mistakes are modeling mistakes. + +## Providers wrap a single API surface + +A provider abstracts one platform's API/SDK into Terraform's lifecycle. Keep +out of a provider: + +- Raw HTTP/protocol clients bolted onto an SDK-based provider +- Functionality that requires extra binaries or agents on the host running + Terraform +- Data sources whose only purpose is exporting the provider's own + credentials to other configuration (a credential-exfiltration hazard) + +## Resources: the smallest useful building block + +**Heuristic: if the API offers create/read/(update)/delete for a thing, that +thing is a resource.** Prefer many small resources over one large one — +practitioners compose small blocks far more easily than they fight a +mega-resource with intertwined attribute behaviors. + +**One resource, one API.** A resource should call a single service's API. +Cross-service resources look convenient but: + +- force users to grant permissions for services they may not know are + involved, +- scatter audit trails across services, +- break per-service endpoint/partition configuration, and +- silently break one service's users when the other service changes. + +If two services must cooperate, model each side as its own resource and let +configuration connect them. + +## Relationship and lifecycle modeling + +| API concept | Model as | +|---|---| +| Attached policy / rule document | Separate resource referencing the parent, not a blob attribute on the parent | +| One-to-many attachment (e.g. member of group) | Separate "attachment/membership" resource | +| Start/stop/enable/disable running state | An attribute *in* the resource — a separate "power state" resource fights the parent's lifecycle | +| Long-running task / job / operation the API exposes | Separate resource representing the task; Create starts it, Read polls it | +| Invitation / handshake / approval flows | Resource on the accepting side: Create = accept, Read = status, Delete = reject/leave | +| Versioned artifact (function version, template version) | Usually a separate `_version` resource so versions can pin and iterate independently | + +When a single API object has two defensible modelings (one resource with +nested attributes vs. parent + child resources), prefer the one whose +*update* semantics match the API: if children can be added/removed +independently server-side, separate resources avoid the classic +"whole-list replacement" diff problem — but never ship both patterns for the +same underlying collection without conflict warnings in both. + +## Data sources + +Data sources are read-only views. They must not create, modify, or delete +anything, ever — a data source with side effects breaks `terraform plan`'s +promise of being safe to run. + +**Singular data sources** (`examplecloud_widget`) fetch exactly one object: + +- zero matches → error ("no Widget matched; adjust the filters") +- more than one match → error ("multiple Widgets matched; add filters until + exactly one matches") + +Returning an arbitrary element instead of erroring hides real environmental +problems and produces non-deterministic plans. + +**Plural data sources** (`examplecloud_widgets`) fetch a collection: + +- return zero-or-more as a list/set attribute +- zero matches is a *valid, empty* result, not an error +- name them with the plural noun; keep filters consistent with the singular + form + +**Ship both for most resources.** Users need the singular form for point +lookups ("give me this widget by name") and the plural form for enumeration +("give me every widget matching these filters") — different configurations +need different shapes of the same data, and adding the missing one later is +a common feature request. Skip one only when the API genuinely cannot +support it. + +## Naming + +- Resource type: `__`, all lowercase snake_case, + noun derived from the API's own CRUD operation names (`CreateWidget` → + `_widget`) so users can map docs ↔ API. +- Go: factory functions `NewWidgetResource`, correct initialisms in + MixedCaps (`VPCEndpoint`, not `VpcEndpoint`). +- Attribute names: snake_case translations of the API's field names; do not + invent new vocabulary the API's docs won't explain. + +## When *not* to add a resource + +- The "resource" is really an RPC with no persistent object behind it — a + provider **action** may fit better (see the `provider-actions` skill, if + available). +- The object is read-only in the API → data source. +- The object is a singleton account-level setting that cannot be created or + destroyed → resource with Create = adopt/configure and Delete = reset to + defaults, documented as such; or omit it. diff --git a/terraform/provider-development/skills/provider-resources/references/retries-and-waiters.md b/terraform/provider-development/skills/provider-resources/references/retries-and-waiters.md new file mode 100644 index 0000000..8ff0776 --- /dev/null +++ b/terraform/provider-development/skills/provider-resources/references/retries-and-waiters.md @@ -0,0 +1,153 @@ +# Retries, Waiters, and Eventual Consistency + +Most real APIs are eventually consistent: a successful Create response does +not mean the object is ready, visible, or even findable yet. Providers that +ignore this produce flaky applies that "work on retry" — the worst kind of +bug report. This reference gives the standard patterns; the primitives +(`retry.StateChangeConf`, `retry.NotFoundError`) come from +`github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry` and are usable +from Plugin Framework providers. + +## Three failure classes + +1. **Not yet visible** — Create returned, but an immediate Get 404s because + replicas haven't converged. Fix: retry *not-found* errors briefly right + after create. +2. **Temporarily refused** — the API rejects an operation because a + dependency hasn't propagated (auth policy, newly created parent). Fix: + retry on the *specific* error for a bounded window. +3. **Asynchronous completion** — the API accepts the request and works in + the background (status field: `CREATING` → `ACTIVE`). Fix: a waiter that + polls status until it reaches a target. + +Diagnose which class you have before coding; the fixes look similar but +retry different conditions. + +## Waiters: status + wait function pairs + +Split waiting into a *status* function (one poll, built on the package's +finder) and a *wait* function (the state machine). Keeping them separate +makes each waiter one obvious declaration and lets tests call the status +function directly. + +```go +const ( + widgetCreatedTimeout = 5 * time.Minute + widgetDeletedTimeout = 10 * time.Minute +) + +func statusWidget(ctx context.Context, client *examplecloud.Client, id string) retry.StateRefreshFunc { + return func() (any, string, error) { + output, err := findWidgetByID(ctx, client, id) + if isNotFound(err) { + return nil, "", nil // nil result, empty status = "gone" + } + if err != nil { + return nil, "", err + } + return output, string(output.Status), nil + } +} + +func waitWidgetCreated(ctx context.Context, client *examplecloud.Client, id string) (*examplecloud.Widget, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{"CREATING", "PENDING"}, + Target: []string{"ACTIVE"}, + Refresh: statusWidget(ctx, client, id), + Timeout: widgetCreatedTimeout, + } + outputRaw, err := stateConf.WaitForStateContext(ctx) + if output, ok := outputRaw.(*examplecloud.Widget); ok { + return output, err + } + return nil, err +} + +func waitWidgetDeleted(ctx context.Context, client *examplecloud.Client, id string) error { + stateConf := &retry.StateChangeConf{ + Pending: []string{"ACTIVE", "DELETING"}, + Target: []string{}, // empty target: wait until the status func reports gone + Refresh: statusWidget(ctx, client, id), + Timeout: widgetDeletedTimeout, + } + _, err := stateConf.WaitForStateContext(ctx) + return err +} +``` + +Rules of thumb: + +- **Enumerate `Pending` states** you expect to pass through; an unexpected + state fails fast with a clear error instead of hanging to timeout. Include + failure states (`FAILED`, `ERROR`) in neither list so they error + immediately — or check for them in the status function and return a + descriptive error carrying the API's failure reason. +- **Timeouts in named constants**, generous but bounded. If users of the + resource legitimately need control, add schema-level timeouts. +- Call `waitWidgetCreated` at the end of `Create` (and `waitWidgetDeleted` + in `Delete`) so downstream resources can rely on readiness. + +## Post-create not-found retries + +For class 1 (visible-lag) failures, wrap the first read after create in a +short not-found retry rather than a full waiter: + +```go +const propagationTimeout = 2 * time.Minute + +func findWidgetByIDRetryOnCreate(ctx context.Context, client *examplecloud.Client, id string) (*examplecloud.Widget, error) { + var output *examplecloud.Widget + err := retry.RetryContext(ctx, propagationTimeout, func() *retry.RetryError { + var err error + output, err = findWidgetByID(ctx, client, id) + if isNotFound(err) { + return retry.RetryableError(err) // just created: keep looking + } + if err != nil { + return retry.NonRetryableError(err) + } + return nil + }) + return output, err +} +``` + +Only use this immediately after create. In a normal `Read`, a not-found must +*not* be retried — it is the signal to remove the resource from state. + +## Operation-specific error retries + +For class 2, retry only the specific, recognizable error, for a bounded +window: + +```go +err := retry.RetryContext(ctx, propagationTimeout, func() *retry.RetryError { + _, err := client.AttachPolicy(ctx, input) + var conflictErr *examplecloud.DependencyNotReadyError + if errors.As(err, &conflictErr) { + return retry.RetryableError(err) + } + if err != nil { + return retry.NonRetryableError(err) + } + return nil +}) +``` + +Never retry on error *message substrings* if the SDK offers typed errors, +and never retry broad classes ("any 400") — that converts real +misconfigurations into 2-minute hangs followed by a confusing timeout. + +## Attribute-value waiters for updates + +When an update is itself asynchronous (the API acknowledges but the field +reads back stale), wait for the attribute to reach its planned value using +the same StateChangeConf shape, with the attribute value as the "status". +Symptoms that you need this: tests fail on the post-apply refresh plan with +a diff on the just-updated attribute. + +## Where this code lives + +Small providers: same file as the resource. Larger packages: `status.go` and +`wait.go` per the file taxonomy in the main skill, so every resource's +waiters are discoverable in one place.