mcpField
P
provider-resources

provider-resources

github.com/hashicorp开发9094.1k 次安装今天更新
代码仓库

关于 provider-resources

面向 Provider 开发者的 Terraform 资源与数据源实现指南技能。它建议新代码采用 Plugin Framework,并涵盖 CRUD 行为、Schema 设计、状态管理、测试及文档生成。

主要功能

  1. 01新资源采用 Plugin Framework 的指导
  2. 02CRUD 与导入实现模式
  3. 03资源和数据源设计原则
  4. 04Schema、验证与状态管理
  5. 05验收测试模式
  6. 06使用 tfplugindocs 生成文档

适用场景

  1. 01为 Terraform Provider 添加资源
  2. 02构建只读 Provider 数据源
  3. 03迁移或维护 SDKv2 资源
  4. 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 类型使用场景
stringschema.StringAttribute名称、标识符
numberschema.Int64Attribute、schema.Float64Attribute计数、大小
boolschema.BoolAttribute功能标志
listschema.ListAttribute有序集合
setschema.SetAttribute无序且唯一的项目
mapschema.MapAttribute键值对
objectschema.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, &notFound) {
    // 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)

参考资料

评论

支持 Markdown