关于 provider-resources
面向 Provider 开发者的 Terraform 资源与数据源实现指南技能。它建议新代码采用 Plugin Framework,并涵盖 CRUD 行为、Schema 设计、状态管理、测试及文档生成。
主要功能
- 01新资源采用 Plugin Framework 的指导
- 02CRUD 与导入实现模式
- 03资源和数据源设计原则
- 04Schema、验证与状态管理
- 05验收测试模式
- 06使用 tfplugindocs 生成文档
适用场景
- 01为 Terraform Provider 添加资源
- 02构建只读 Provider 数据源
- 03迁移或维护 SDKv2 资源
- 04测试 Provider 生命周期与导入行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ hashicorp/ agent-skills --skill provider-resources安装后在 Claude Code 中输入/provider-resources确认已加载。
附带文件references/design-principles.mdreferences/retries-and-waiters.md
详细介绍
Terraform Provider 资源实现指南
概述
本指南介绍如何开发 Terraform Provider 资源和数据源。 资源表示由 Terraform 通过创建、读取、更新和删除(CRUD)操作管理的基础设施对象。
所有全新资源和数据源都应使用 Plugin Framework。 Plugin SDKv2 用于维护已有资源;请勿基于它编写新代码。迁移期间,Provider 可以通过 mux 同时提供两者
(terraform-plugin-mux),因此采用 Framework 无需一次性重写。要判断现有 Provider 使用哪种模式,请检查 go.mod:包含 terraform-plugin-mux 表示同时提供 SDKv2 和 Framework 代码;仅包含 terraform-plugin-sdk/v2 表示仅使用 SDKv2;仅包含 terraform-plugin-framework 表示仅使用 Framework。迁移现有 SDKv2 资源时请谨慎:Framework 会区分 null 值和零值,因此简单迁移会改变现有用户的使用行为(如果可用,请使用 provider-framework-migration skill)。
参考资料(按需加载):
references/design-principles.md— 哪些内容适合(或不适合)做成资源;数据源语义;关系与异步任务建模references/retries-and-waiters.md— 最终一致性、重试模式,以及状态函数和等待函数的结构
文件结构
大多数 Provider 会将每个资源放在一个包中:
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
大型多服务 Provider(例如 terraform-provider-aws)则会拆分到 internal/service/<service>/ 包中,并采用惯用的文件分类法;随着包规模扩大,值得采用这种分类法:consts.go、find.go(查找器)、status.go(状态函数)、wait.go(等待器)、sweep.go(测试清理器)、exports_test.go。
文档位于 docs/,并使用 tfplugindocs 生成:
docs/
├── resources/<name>.md # generated; optional <name>.md.tmpl template
└── data-sources/<name>.md
(一些较早的大型 Provider 中仍有手写的 website/docs/r/*.html.markdown 目录;编辑这类文档时,请遵循目标仓库的约定。)
资源结构
Framework 资源是一个持有 API 客户端的结构体,并通过接口断言明确其实现的行为:
var (
_ resource.Resource = &widgetResource{}
_ resource.ResourceWithConfigure = &widgetResource{}
_ resource.ResourceWithImportState = &widgetResource{}
)
func NewWidgetResource() resource.Resource {
return &widgetResource{}
}
type widgetResource struct {
client *examplecloud.Client
}
func (r *widgetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_widget"
}
// 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 *widgetResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthBetween(1, 255),
},
},
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
Provider 的 Configure 如何生成该客户端(包括架构、凭证解析和验证),由 provider-configuration skill 介绍(如果可用)。
关于 id: SDKv2 要求使用特殊的 id 属性;Framework 不需要。如果 API 有自己的标识符,请按其实际含义公开,不要再添加重复的 id。只有当 id 就是 API 标识符时才保留它(如上所示)。
CRUD 操作
创建
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
}
input := &examplecloud.CreateWidgetInput{
Name: data.Name.ValueStringPointer(),
}
output, err := r.client.CreateWidget(ctx, input)
if err != nil {
resp.Diagnostics.AddError(
"Error creating Widget",
fmt.Sprintf("creating Widget (%s): %s", data.Name.ValueString(), err),
)
return
}
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)...)
}
读取
读取时必须处理带外删除:将资源从状态中移除,以便下次计划时重新创建,而不是永远报错:
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
}
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 Widget",
fmt.Sprintf("reading Widget (%s): %s", data.ID.ValueString(), err),
)
return
}
data.Name = types.StringPointerValue(output.Name)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
更新
仅对实际发生变化的属性调用 API;将计划与状态进行比较:
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
}
if !plan.Description.Equal(state.Description) {
input := &examplecloud.UpdateWidgetInput{
ID: plan.ID.ValueStringPointer(),
Description: plan.Description.ValueStringPointer(),
}
if _, err := r.client.UpdateWidget(ctx, input); err != nil {
resp.Diagnostics.AddError(
"Error updating Widget",
fmt.Sprintf("updating Widget (%s): %s", plan.ID.ValueString(), err),
)
return
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
删除
将“已不存在”视为成功——这意味着已达到期望的最终状态:
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
}
_, err := r.client.DeleteWidget(ctx, &examplecloud.DeleteWidgetInput{
ID: data.ID.ValueStringPointer(),
})
if isNotFound(err) {
return
}
if err != nil {
resp.Diagnostics.AddError(
"Error deleting Widget",
fmt.Sprintf("deleting Widget (%s): %s", data.ID.ValueString(), err),
)
return
}
}
导入
断言实现了 ResourceWithImportState 后,透传标识符只需一行代码:
func (r *widgetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
对于多部分标识符,请解析以分隔符分隔的导入 ID(通常用逗号分隔),并显式设置每个属性。
资源设计原则
在实现之前,先检查所建模事物的形态(完整说明见 references/design-principles.md):
- 资源是最小的实用构建块;如果 API 为它提供 CRUD 操作,它很可能值得拥有自己的资源。
- 一个资源应只与一个 API/服务交互——跨服务资源会破坏权限、审计和端点配置。
- 数据源是只读且无副作用的。单数数据源在匹配结果为零个或多个时会报错;复数数据源(使用复数名词命名)会将零个或多个匹配结果作为集合返回,未匹配到任何结果时会报错。
- 附加策略/规则、长时间运行的任务调用以及带版本的制品通常值得拥有自己的资源,而不是作为父资源的属性。
- 启动/停止或启用/禁用状态应作为资源内的属性,而不是单独的资源。
架构设计
属性类型
| Terraform 类型 | Framework 类型 | 使用场景 |
|---|---|---|
string | schema.StringAttribute | 名称、标识符 |
number | schema.Int64Attribute、schema.Float64Attribute | 计数、大小 |
bool | schema.BoolAttribute | 功能标志 |
list | schema.ListAttribute | 有序集合 |
set | schema.SetAttribute | 无序且唯一的项目 |
map | schema.MapAttribute | 键值对 |
object | schema.SingleNestedAttribute | 复杂的嵌套配置 |
为每个属性提供 MarkdownDescription——tfplugindocs 会发布该描述,它是面向用户的主要文档。
计划修饰器
// Force replacement when value changes
stringplanmodifier.RequiresReplace()
// Keep a known value during plan instead of (known after apply)
stringplanmodifier.UseStateForUnknown()
验证器
stringvalidator.LengthBetween(1, 255)
stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z0-9-]+$`), "must be lowercase alphanumeric with hyphens")
stringvalidator.OneOf("small", "medium", "large")
int64validator.Between(1, 100)
listvalidator.SizeAtLeast(1)
敏感属性
"password": schema.StringAttribute{
Required: true,
Sensitive: true,
},
状态管理
查找器
将“获取一个对象或返回带类型的未找到错误”集中到查找器中,以确保 Read、Delete、等待器和测试共用一致的未找到语义:
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 apiErr *examplecloud.NotFoundError
if errors.As(err, &apiErr) {
return nil, &retry.NotFoundError{LastError: err}
}
return nil, fmt.Errorf("getting Widget (%s): %w", id, err)
}
if output == nil || output.Widget == nil {
return nil, &retry.NotFoundError{Message: "empty result"}
}
return output.Widget, nil
}
func isNotFound(err error) bool {
var nfe *retry.NotFoundError
return errors.As(err, &nfe)
}
等待资源状态
许多 API 会在资源可用/消失之前就从 Create/Delete 返回。请使用 retry.StateChangeConf(来自
github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry——可用于 Framework 提供程序),并使用基于查找器构建的状态函数,以及命名常量表示超时时间:
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)
完整的状态/等待函数组合(创建和删除等待器、失败状态处理、创建后未找到时的重试、最终一致性模式)见 references/retries-and-waiters.md——如果 API 是异步的或具有最终一致性,请阅读该文档。
测试
每个资源至少应包含:
_basic——使用最小配置创建,断言属性,然后执行导入步骤(ImportState: true、ImportStateVerify: true)_disappears——在测试中途从带外删除对象;下一次计划必须建议重新创建,而不是报错- 按属性测试——为每个重要参数测试更新操作
命名规则:测试为 TestAcc{Resource}_{group?}_{description},辅助函数为 testAccCheck{Resource}Exists/testAccCheck{Resource}Destroy,配置函数为 testAcc{Resource}Config_{description}。配置应保持自包含,对真实资源名称进行随机化,并且绝不要硬编码特定环境的值(账户 ID、区域、版本)。
func TestAccWidget_basic(t *testing.T) {
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
resourceName := "examplecloud_widget.test"
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckWidgetDestroy,
Steps: []resource.TestStep{
{
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,
ImportState: true,
ImportStateVerify: true,
},
},
})
}
func testAccWidgetConfig_basic(rName string) string {
return fmt.Sprintf(`
resource "examplecloud_widget" "test" {
name = %[1]q
}
`, rName)
}
如需完整的测试说明,请使用 provider-test-patterns 技能(如果可用):配置辅助函数风格(%[1]q 索引动词)、statecheck/plancheck、CompareValue、用于 exists/disappears 辅助函数的自定义 StateCheck 实现、清理器以及临时资源测试。使用 run-acceptance-tests 技能来运行和调试测试。
错误处理
按 API 错误的类型而非消息文本进行匹配,并添加上下文信息:
var notFound *examplecloud.NotFoundError
if errors.As(err, ¬Found) {
// resource doesn't exist
}
// Wrapping inside helpers: preserve the cause with %w
return fmt.Errorf("creating Widget (%s): %w", name, err)
诊断信息遵循一致的格式——摘要说明操作和类型,详细信息包含标识符和原因:
resp.Diagnostics.AddError(
"Error creating Widget",
fmt.Sprintf("creating Widget (%s): %s", name, err),
)
resp.Diagnostics.AddAttributeError(
path.Root("name"),
"Invalid name",
"Name must be lowercase alphanumeric",
)
文档
先编写属性的 MarkdownDescription——它们是权威来源。然后使用 tfplugindocs 生成 Registry 文档(已配置的项目可运行 go generate ./...),仅在生成器无法推导出文案和示例时,才添加 docs/**/*.md.tmpl 模板。完整的文档工作流和 Registry 发布规则,请使用 provider-docs skill(如果可用)。
提交前检查清单
- 使用 Plugin Framework(不编写新的 SDKv2 代码)
- 资源已实现所有 CRUD 操作
- Read 会从状态中移除缺失的资源;Delete 可处理资源已删除的情况
- 没有多余的
id属性(应公开真实的 API 标识符) - 已实现导入,并包含
ImportStateVerify步骤 - 已提供
_basic、_disappears和逐属性测试 - API 最终一致时使用 Waiter
- 错误消息包含操作、类型和标识符
- 已将敏感属性标记为敏感;每个属性都有描述
- 已使用
tfplugindocs生成文档 - 如果仓库跟踪发布说明,则已添加变更日志条目(请查看 CONTRIBUTING)