さくらのTerraformプロバイダの現状
さくらでは、さくらのサービスをTerraformで管理するためのプロバイダを提供しています。今まではterraform-provider-sakuracloudという名前でv2を提供していましたが、現在はterraform-provider-sakuraという名前でv3を開発しています。v2/v3の主な違いは以下です。
- v2ではSDK v2というライブラリを利用しているが、v3はFrameworkというSDK v2の後継を利用している
- それにともない非推奨な機能を削除しており互換性はない。単純な書き換えで済むようにはしている
- 今年提供された新規サービス群はまずv3に実装されるので、新しい機能を試しやすい
1番が問題で、Terraform開発陣がSDK v2のメンテナンスの優先度を下げており、新しくプロバイダを書くにはFrameworkを推奨しています。例えばさくらで起きた問題を調査したところ、issueは上がっているものの修正が難しくFrameworkを使ってくれと解決される見込みのない問題もあったりしました。なので、今はFrameworkへ完全移植するためv3を開発しています。
実際Frameworkでは、SDK v2のanyを使ったキャストだらけのコードから型を使った設計に変わったので、書きやすくなったと感じています。移植の過程で、これは本来間違ってるけどなんとなく動いていたコードを見つけられたのもメリットです。
今回の記事は、SDK v2でプロバイダを書いたことがある人向けにFrameworkではこう書けるよと社内向けに公開したドキュメントをベースにしたものとなります。
基本
Resourceが書けたらData Sourceも簡単に書けるので、ここでは主としてResourceに関して書きます。基本事項として、FrameworkはSDK v2と比べてなるべく型をつけて暗黙的な挙動を削り、Provider実装側で明示的に実装するようになっているので、SDK v2のゆるい実装に依存していたコードほど移植が難しくなります。
以降はXXXというResourceを実装する想定でコードを示します。
Resource実装の大まかな違い
- SDK v2: schema.Resourceが共通な上にinterface{}を利用したりと、かなり緩い形での実装となっている
func resourceXXX() *schema.Resource {
return &schema.Resource{
CreateContext: resourceXXXCreate,
UpdateContext: resourceXXXUpdate,
ReadContext: resourceXXXRead,
DeleteContext: resourceXXXDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Required: true,
Description: "Name of XXX",
}
// ...
},
}
}
func resourceXXXCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { //... }
func resourceXXXRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { //... }
func resourceXXXUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { //... }
func resourceXXXDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { //... }
- Framework: 専用のResourceを用意しモデルやメソッドを定義する。各アクション毎に専用のオブジェクトを用意したりなるべく型をつける。基本的にSDK v2の時と比べてコードは長くなる
type xxxResource struct {
// クライアント等リソースで利用する値はここで持つ
client xxx.Client
}
var (
_ resource.Resource = &xxxResource{}
_ resource.ResourceWithConfigure = &xxxResource{}
_ resource.ResourceWithImportState = &xxxResource{}
)
// provider.goはこれを呼び出して登録する
func NewXXXResource() resource.Resource {
return &xxxResource{}
}
func (r *xxxResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_xxx"
}
func (r *xxxResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// SDK v2でmetaで持ってたようなクライアント等はここで設定する
r.client = xxx.SetupClient()
}
// Schemaで設定したフィールドはモデル用の構造体にマッピングしてから扱う
type xxxResourceModel struct {
Name types.String `tfsdk:"name"`
// ...
}
func (r *xxxResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Required: true,
Description: "Name of xxx"
},
// ...
"timeouts": timeouts.Attributes(ctx, timeouts.Opts{
Create: true, Update: true, Delete: true,
}),
},
}
}
// SDK v2でのImporter
func (r *xxxResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
// XXXContextはそれぞれ同名のメソッドで定義する
func (r *xxxResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan xxxResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
// ...
}
func (r *xxxResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state xxxResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// ...
}
func (r *xxxResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan xxxResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
// ...
}
func (r *xxxResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state xxxResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// ...
}
フィールド
Resource/Data Sourceのフィールドに関して、Frameworkではtypes.Typeを用いて3つの状態で表現されるようになった。
- 実際の値: 設定で値が指定されていて
ValueTypeメソッドで実際の値が取れる状態。 - Null: Optionalなフィールドで値が設定されていない等、値が存在しない時の状態。
IsNullでチェックできる。 - Unknown: 他のリソースに依存しているフィールドや
Computedなフィールドの未設定な状態。IsUnknownでチェックできる。
コード内で実際の値を取る時には、Required以外のフィールドでは以下のようにする。
if plan.AttrX.IsNull() || plan.AttrX.IsUnknown() {
return
}
v := plan.AttrX.ValueString() // Null/Unknownでは""が返るので、それを許容できる場合はチェックはいらない
Frameworkではフィールドの状態もなるべくチェックしている。例えばOptionalなフィールドが指定されておらずNullの状態に設定ではなっているのに、コードで値を代入したりすると"Inconsistent state"エラーが起きる。SDK v2ではこれらのチェックはしておらず、なんとなく動くという状態になっていた。
ID
SDK v2ではidフィールドが組み込みで存在しておりSetIdメソッドで設定していたが、Frameworkでは他のフィールドと同じように自前で実装する必要がある。SDK v2相当の実装は以下のようになる。
// モデル
ID types.String `tfsdk:"id"`
// スキーマ
schema.StringAttribute{
Computed: true,
Description: "The ID of the XXX.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
}
idのように一度設定されたら使い続ける用途のComputedフィールドに関しては毎回Unknownになると困るので、PlanModifierのUseStateForUnknownを使うことで、保存されたStateから値を取得して回避する。
ForceNew
値が変更されたらin-place updateではなくre-createするのを指定するForceNewは各型のplanmodifierで指定するようになった。
schema.StringAttribute{
Required: true,
Description: "The important attribute",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
}
RequiresReplace系は用途に合わせて複数存在する: https://developer.hashicorp.com/terraform/plugin/framework/resources/plan-modification#common-use-case-attribute-plan-modifiers
バリデーション
指定された値のバリデーションを行うValidateDiagFuncは各型のValidatorsで指定するようになった。
"port": schema.Int32Attribute{
Required: true,
Description: "The destination port number.",
Validators: []validator.Int32{
int32validator.Between(1, 65535),
},
},
Validatorの実装も、Frameworkでは構造体を用意してメソッドを実装するアプローチに変わっている: https://github.com/sacloud/terraform-provider-sakura/tree/main/internal/validator
Conflctチェック
他のフィールドとのコンフリクトをチェックするConflictsWithは各型のValidatorsに統合された。
Validators: []validator.String{
stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("other_field")), // path.MatchRootでルートからの指定も可能
},
タイムアウト
SDK v2で組み込みで提供されていたタイムアウトの機能は https://github.com/hashicorp/terraform-plugin-framework-timeouts に分離された。このモジュールを使って自分で定義して呼び出す必要がある。
// モデル
Timeouts timeouts.Value `tfsdk:"timeouts"`
// スキーマ
"timeouts": timeouts.Attributes(ctx, timeouts.Opts{
Create: true, Update: true, Delete: true,
}),
// 各メソッドで呼び出す
createTimeout, err := plan.Timeouts.Create(ctx, 20*time.Minute)
if err != nil {
// handle error
}
ctx, cancel := context.WithTimeout(ctx, createTimeout)
defer cancel()
terraform-provider-sakuraではヘルパーを用意しているので、よりコンパクトに書けるようになっている。
ctx, cancel := common.SetupTimeoutCreate(ctx, plan.Timeouts, 20*time.Minute)
defer cancel()
HasChanges
SDK v2で設定が以前と変更されていたかどうかをチェックするHasChanges(HasChange)は削除されたので、自分で比較する必要がある。代替コードは以下にようになる。
var plan, state xxxResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if !plan.AttrX.Equal(state.AttrX) {
// 変更があった時だけ処理
}
リソースの削除
SDK v2とは違い削除用のメソッドが用意されている。
// SDK v2
d.SetId("")
// Framework
resp.State.RemoveResource(ctx)
長さが1のList
歴史的経緯もあり長さのMin/Maxを1に設定したListでブロックを実装していることがあるが、FrameworkではSingleNestedAttributeで実装する。SingleNestedBlockも用意されているが、Blockは互換性のための機能であり将来削除される可能性があるので、Attributeに統一することが推奨されている。
// 設定
attrs = {
"foo" = "bar"
// ...
}
// 実装
"attrs": schema.SingleNestedAttribute{
Optional: true,
Description: "Nested attributes",
Attributes: map[string]schema.Attribute{
"foo": schema.StringAttribute{
Required: true,
Description: "foo value",
},
// ...
},
},
SingleNestedAttribute等のネストされたフィールドとモデルのマッピング
Required/Optionalなフィールドであれば、直接構造体にマッピングする事が可能。
// モデル
type xxxResourceModel struct {
Name types.String `tfsdk:"name"`
Attrs *xxxAttrsModel `tfsdk:"attrs"`
}
type xxxAttrsModel struct {
X types.String `tfsdk:"x"`
// ...
}
// スキーマ
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
// ...
},
"attrs": schema.SingleNestedAttribute{
Required: true,
Attributes: map[string]schema.Attribute{
"x": schema.StringAttribute{
// ...
},
// ...
},
},
}
}
ListNestedAttributeであれば配列で処理可能。
Objects []xxxObjectModel `tfsdk:"objects"`
直接構造体を指定する方法ではComputedパラメータで発生するUnknownは表現できないので、そういうケースではtypes.Objectを経由する。
// モデル
type xxxResourceModel struct {
Name types.String `tfsdk:"name"`
Attrs types.Object `tfsdk:"attrs"`
}
type xxxAttrsModel struct {
X types.String `tfsdk:"x"`
// ...
}
// 変換用のヘルパーメソッドを用意
func (m xxxAttrsModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"x": types.StringType,
// ...
}
}
// スキーマ
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
// ...
},
"attrs": schema.SingleNestedAttribute{
Optional: true,
Computed: true,
Attributes: map[string]schema.Attribute{
"x": schema.StringAttribute{
// ...
},
// ...
},
},
}
}
// モデルからXXX向けに値を取り出す
func expandXXXAttrs(model *xxxResourceModel) *xxxAttrs {
if model.Attrs.IsNull() || model.Attrs.IsUnknown() {
return nil
}
var d xxxAttrsModel
diags := model.Attrs.As(context.Background(), &d, basetypes.ObjectAsOptions{})
if diags.HasError() {
return nil
}
return &xxxAttrs{
X: d.X.ValueString(),
// ...
}
}
// XXXからモデルに値を設定する
func flattenXXXAttrs(xxx XXX) types.Object {
v := types.ObjectNull(xxxAttrsModel{}.AttributeTypes())
if xxx.Attrs != nil {
m := xxxAttrsModel{
X: types.StringValue(xxx.Attrs.X),
// ...
}
value, diags := types.ObjectValueFrom(context.Background(), m.AttributeTypes(), m)
if diags.HasError() {
return v
}
return value
}
return v
}
特殊な事例
あるアクションから他のアクションの呼び出し
SDK v2ではCreate/Read/Update/Deleteの各関数の引数が同じであり、あるアクションから他のアクションを呼び出すことで処理を共通化する手法がよく利用されていた。
func resourceXXXCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
// ...
return resourceXXXRead(ctx, d, meta) // リソースの状態の取得・設定はRead経由
}
Frameworkでは型が違うので推奨できない。特にCreate -> ReadではReadはPlanではなくStateからモデルに値を設定するので、状態のミスマッチが起きる。限定的な状況ではstate/diagsフィールドを詰め替えることで実現可能だが拡張性がかなり制限されるので、共通している処理を関数化して各アクション間に依存を持たせない実装にするのが無難。
UseStateForUnknownなComputedフィールドを更新する
ComputedかつUseStateForUnknown()を利用しているフィールドは一度計算されてStateに保存されたあとはその値が利用され続ける(id等)。特殊な事情があってその値を更新する必要がある場合にはModifyPlanメソッドを使って更新したいフィールドをUnknownにすることで更新可能となる。
// AttrXの値が変更されたらidを更新する
func (r *xxxResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
var plan, state *xxxResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if state == nil || plan == nil {
return
}
if plan.AttrX.ValueString() != state.AttrX.ValueString() {
resp.Plan.SetAttribute(ctx, path.Root("id"), types.StringUnknown())
}
}
もちろん、こういう実装はなるべく回避すべきではある。
設定された値を変更する
SDK v2では値のチェックが厳密に行われていなかったため、設定ファイル等で設定された値を書き換えることができたが、Frameworkではエラーとなる。
// 設定
values = ["a", "b"]
// Create処理後の疑似コード
plan.Values = ["a", "b", "c"] // SDK v2では動くがFrameworkではエラーとなる
computed_valuesのようなフィールドを用意して分ける必要がある。