provider-resources について
Terraformプロバイダーのリソースとデータソースの実装を支援する、開発者向けガイドスキルです。新規コードにはPlugin Frameworkを推奨し、CRUD、スキーマ設計、状態管理、テスト、ドキュメント生成を扱います。
主な機能
- 01新規リソース向けPlugin Frameworkの指針
- 02CRUDとインポートの実装パターン
- 03リソースとデータソースの設計原則
- 04スキーマ、検証、状態管理
- 05受け入れテストのパターン
- 06tfplugindocsによるドキュメント生成
活用シーン
- 01Terraformプロバイダーへのリソース追加
- 02読み取り専用データソースの構築
- 03SDKv2リソースの移行または保守
- 04プロバイダーのライフサイクルとインポートのテスト
インストール
コマンドをコピーしてターミナルで実行します。
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 プロバイダーのリソース実装ガイド
概要
このガイドでは、Terraform プロバイダーのリソースとデータソースの開発について説明します。 リソースは、Terraform が Create、Read、Update、Delete(CRUD)操作を通じて管理するインフラストラクチャオブジェクトを表します。
新規に作成するすべてのリソースとデータソースには、Plugin Frameworkを使用してください。 Plugin SDKv2 は、すでに存在するリソースの保守に使用するものであり、新しいコードを記述するために使用しないでください。プロバイダーはマイグレーション中に、両方のコードをマルチプレックスすることで共存させられます(terraform-plugin-mux)。そのため、Framework の採用にあたって一度にすべてを書き換える必要はありません。既存のプロバイダーがどのモードであるかは、go.modを確認してください。terraform-plugin-muxがあれば SDKv2 と Framework の両方を提供し、terraform-plugin-sdk/v2のみであれば SDKv2 のみ、terraform-plugin-frameworkのみであれば Framework のみです。既存の SDKv2 リソースのmigrationには注意してください。Framework では null 値とゼロ値が区別されるため、単純なマイグレーションでも既存ユーザーの動作が変わることがあります(利用可能であれば、provider-framework-migrationスキルを使用してください)。
参考資料(必要に応じて読み込んでください):
references/design-principles.md— リソースにすべきもの(およびすべきでないもの)、データソースのセマンティクス、関係と非同期タスクのモデル化references/retries-and-waiters.md— 結果整合性、再試行のパターン、ステータス関数と待機関数の構造
ファイル構成
ほとんどのプロバイダーでは、すべてのリソースを単一のパッケージにまとめています。
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
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
(一部の古い大規模プロバイダーには、手書きの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(),
},
},
},
}
}
プロバイダーのConfigureがクライアントをどのように生成するか(スキーマ、認証情報の解決、検証)については、利用可能であればprovider-configurationスキルで説明しています。
idについて: SDKv2 では特別なid属性が必須でしたが、Framework では必須ではありません。API が独自の識別子を持つ場合は、その実際の意味に沿った名前で公開し、重複するidを追加しないでください。上記の例のように、API の識別子そのものである場合に限り、idを保持してください。
CRUD 操作
Create
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)...)
}
Read
Read では、外部でリソースが削除された場合に、エラーを出し続けるのではなく、状態からリソースを削除して次のプランで再作成できるようにする必要があります。
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)...)
}
Update
状態とプランを比較し、実際に変更された属性に対してのみ 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)...)
}
Delete
「すでに存在しない」場合も成功として扱ってください。目標とする最終状態に到達しているためです。
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
}
}
Import
ResourceWithImportStateをアサートする場合、識別子をそのまま受け渡す処理は1行で記述できます。
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/サービスは 1 つだけにしてください。複数のサービスにまたがるリソースは、権限、監査、エンドポイント設定に支障をきたします。
- データソースは読み取り専用で、副作用がありません。単数形のデータソースは、一致がゼロ件または複数件の場合にエラーになります。複数形のデータソース(複数形の名詞名)は、コレクションとして 0 件以上を返し、該当がない場合はエラーになります。
- アタッチされたポリシー/ルール、長時間実行されるタスクの呼び出し、バージョン管理された成果物は、通常、親リソースの属性ではなく、独立したリソースにするのが適切です。
- 開始/停止や有効化/無効化の状態は、独立したリソースではなく、リソース内の属性として扱います。
スキーマ設計
属性の型
| 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,
},
状態管理
ファインダー
「対象を 1 つ取得するか、型付きの not-found を返す」処理をファインダーに集約し、Read、Delete、待機処理、テストで同一の not-found の意味を共有できるようにします。
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)
ステータス関数と待機関数の完全な組み合わせ(作成および削除の待機処理、失敗状態の処理、作成後の not-found 再試行、結果整合性のパターン)は 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、存在/消失ヘルパー向けのカスタム 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 テンプレートを追加します。利用可能な場合は provider-docs スキルを使って、ドキュメント作成の全ワークフローと Registry への公開ルールを確認してください。
提出前のチェックリスト
- Plugin Framework を使用している(新しい SDKv2 コードを追加していない)
- リソースの CRUD 操作をすべて実装している
- Read では存在しないリソースをステートから削除し、Delete ではすでに削除済みの場合も許容する
- 冗長な
id属性がない(代わりに実際の API 識別子を公開している) - インポートを実装し、
ImportStateVerifyステップで検証している -
_basic、_disappears、および属性ごとのテストがある - API が結果整合性を採用している箇所で待機処理を使用している
- エラーメッセージに操作、型、識別子を明記している
- 機密属性にマークを付け、すべての属性に説明を記載している
-
tfplugindocsでドキュメントを生成している - リポジトリでリリースノートを管理している場合、変更履歴を追加している(CONTRIBUTING を確認)