[Terraform] terraform-plugin-frameworkでproviderを作成してみる

概要

Terraformと外部サービスを接続するプラグインのことをプロバイダー(provider)と呼びます。
たとえばhashicorp/awsをインストールしてaws_instanceを呼び出すと、EC2インスタンスを作成することができますが、これはawsプロバイダーがTerraformで指定された内容に従ってAWS SDKを実行しているからです。
awsプロバイダーのコードはGitHubで公開されています。

TerraformのプロバイダーはGo言語で作成します。
この記事では、terraform-plugin-frameworkを用いて、Misskeyのノートを管理するresourceを作成してみます。
コードは以下のGitHubレポジトリで公開しています。
https://github.com/maeda6uiui/terraform-provider-misskey

providerの定義

ファイルパスはprovider/internal/provider/provider.goです。
コード全体はGitHubの方を確認していただくとして、ここでは重要そうな部分だけ抜粋します。

func (p *MisskeyProvider) Metadata(
	ctx context.Context,
	req provider.MetadataRequest,
	resp *provider.MetadataResponse) {
	resp.TypeName = "misskey"
	resp.Version = p.version
}

func (p *MisskeyProvider) Schema(
	ctx context.Context,
	req provider.SchemaRequest,
	resp *provider.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"server_url": schema.StringAttribute{
				Required:            true,
				MarkdownDescription: "URL of the Misskey server",
			},
			"access_token": schema.StringAttribute{
				Optional:            true,
				Sensitive:           true,
				MarkdownDescription: "Access token for the Misskey account",
			},
			"timeout_seconds": schema.Int32Attribute{
				Optional:            true,
				MarkdownDescription: "Timeout for HTTP requests to the Misskey server",
			},
		},
	}
}

func (p *MisskeyProvider) Configure(
	ctx context.Context,
	req provider.ConfigureRequest,
	resp *provider.ConfigureResponse) {
	var model model.MisskeyProviderModel

	resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
	if resp.Diagnostics.HasError() {
		return
	}

	var accessToken string
	if !model.AccessToken.IsNull() && !model.AccessToken.IsUnknown() {
		accessToken = model.AccessToken.ValueString()
	} else {
		accessToken = os.Getenv("MISSKEY_ACCESS_TOKEN")
	}

	if accessToken == "" {
		resp.Diagnostics.AddError(
			"Missing Access Token",
			"The provider argument 'access_token' or the environment variable 'MISSKEY_ACCESS_TOKEN' must be set.",
		)
		return
	}
	model.AccessToken = types.StringValue(accessToken)

	var timeoutSeconds int32
	if !model.TimeoutSeconds.IsNull() && !model.TimeoutSeconds.IsUnknown() {
		timeoutSeconds = model.TimeoutSeconds.ValueInt32()
	} else {
		timeoutSeconds = 5
	}
	model.TimeoutSeconds = types.Int32Value(timeoutSeconds)

	resp.ResourceData = &model
	resp.DataSourceData = &model
}

func (p *MisskeyProvider) Resources(ctx context.Context) []func() resource.Resource {
	return []func() resource.Resource{
		note.NewNoteResource,
	}
}

func (p *MisskeyProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
	return []func() datasource.DataSource{
		note.NewNoteDataSource,
	}
}

Metadataではこのproviderの情報を定義します。

Schemaでは、Terraformのproviderブロックで受け取る値を定義します。
今回の場合、server_urlが必須なので、ユーザーは以下のようにしてserver_urlを指定する必要があります。

provider "misskey" {
  server_url = "https://misskey-dabansky.com"
}

Misskeyを操作するためにはアクセストークンが必要ですが、アクセストークンをハードコードするのは適切ではないため、環境変数から受け取ることができるようにしたいです。
また、タイムアウトについては必須ではなく、指定されなかった場合はデフォルトの値を使うようにしたいです。
このような処理はSchemaでは実現できないので、Configureで実行します。

Schemaで定義されたスキーマに対応するモデル(struct)を定義しておいて、req.Config.Get(ctx, &model)でモデルに値をセットします。
モデルは以下のように定義します。

type MisskeyProviderModel struct {
	ServerUrl      types.String `tfsdk:"server_url"`
	AccessToken    types.String `tfsdk:"access_token"`
	TimeoutSeconds types.Int32  `tfsdk:"timeout_seconds"`
}

Terraformのステートの実体はJSONファイルですが、tfsdkで指定した名前をキーにしてJSONファイルに値が保存されます。
providerのスキーマの場合はステートには保存されませんが、resourceの場合はtfsdkで指定した値が使用されます。

providerブロックから値を読み込んだら、それをresourceとdata sourceからも参照できるようにします。
以下のようにrespに値をセットします。

resp.ResourceData = &model
resp.DataSourceData = &model

resourceの定義

ファイルパスはprovider/internal/provider/note/resource.goです。

まず、providerブロックから読み込んだ値を取得します。
providerのConfigureresp.ResourceData = &modelというふうにセットしたので、ここでその値を読み込むことができます。

func (r *NoteResource) Configure(
	ctx context.Context,
	req resource.ConfigureRequest,
	resp *resource.ConfigureResponse) {
	if req.ProviderData == nil {
		return
	}

	data, ok := req.ProviderData.(*model.MisskeyProviderModel)
	if !ok {
		resp.Diagnostics.AddError(
			"Unexpected Resource Configure Type",
			fmt.Sprintf("Expected *MisskeyProviderModel, got: %T. Please report this issue to the provider developers.", req.ProviderData),
		)
		return
	}

	misskeyClient := misskey.NewMisskeyHttpClient(
		data.ServerUrl.ValueString(),
		int(data.TimeoutSeconds.ValueInt32()),
		data.AccessToken.ValueString(),
	)
	r.misskeyClient = misskeyClient
}

読み込んだ値を使って、Misskeyのクライアントを作成します。
このクライアントはMisskeyのHTTP APIを実行するためのものです。
HTTPリクエストを投げるだけなので詳細は省きます。
コードはprovider/internal/provider/misskey/client.goを確認してください。

resourceのスキーマ定義もproviderとほとんど同じですが、少し異なる点があります。
たとえばtextはノートの本文を管理するフィールドですが、Misskeyの場合、ノートの本文をin-placeで更新することはできません。
そのため、textが更新された場合はresourceを再作成するようにしたいです。
それを実現するのが、PlanModifiersです。
RequiresReplace()を指定すると、このフィールドが変化した場合にresourceのreplaceが要求されるようになります。

"text": schema.StringAttribute{
	Required:            true,
	MarkdownDescription: "Text of the note",
	PlanModifiers: []planmodifier.String{
		stringplanmodifier.RequiresReplace(),
	},
},

次に、ノートに対するCRUD操作を定義する必要があります。
Createの例を以下に記載します。

planから値を取得します。

var model NoteModel

resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
	return
}

無事にノートを作成できると、作成したノートのIDが取得できるので、それをステートに保存します。

model.Id = types.StringValue(respModel.CreatedNote.Id)

resp.Diagnostics.Append(resp.State.Set(ctx, &model)...)

同様にして、ReadUpdateDeleteを実装します。
Misskeyのノートの場合はin-placeでのupdateは不可能なので、もしUpdateが呼ばれてもエラーになるようにしてあります。

func (r *NoteResource) Update(
	ctx context.Context,
	req resource.UpdateRequest,
	resp *resource.UpdateResponse) {
	// Every attribute requires replacement, so this should never be called
	resp.Diagnostics.AddError(
		"Update Not Supported",
		"Misskey note cannot be updated in place, it must be recreated instead",
	)
}

main関数

エントリーポイントとなるmain関数を実装します。
ファイルパスはprovider/main.goです。

package main

import (
	"context"
	"flag"
	"log"

	"github.com/hashicorp/terraform-plugin-framework/providerserver"
	"github.com/maeda6uiui/terraform-provider-misskey/internal/provider"
)

var (
	version string = "0.0.1-alpha1"
)

func main() {
	var debug bool

	flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve")
	flag.Parse()

	opts := providerserver.ServeOpts{
		Address: "registry.terraform.io/maeda6uiui/misskey",
		Debug:   debug,
	}
	err := providerserver.Serve(context.Background(), provider.New(version), opts)
	if err != nil {
		log.Fatal(err.Error())
	}
}

動作確認

misskey_note resourceの動作確認をするため、Terraformのコードを追加します。
動作確認用のコードはexample/resourceディレクトリにあります。

まず、providers.tfでproviderの設定を行います。
動作確認用なので、ステートはローカル環境に保存します。
providerブロックではserver_urlのみ指定し、アクセストークンは環境変数MISSKEY_ACCESS_TOKENで指定します。

terraform {
  required_providers {
    misskey = {
      source  = "maeda6uiui/misskey"
      version = "0.0.1-alpha1"
    }
  }

  backend "local" {
    path = "terraform.tfstate"
  }

  required_version = "~>1.9"
}

provider "misskey" {
  server_url = "https://misskey-dabansky.com"
}

main.tfは以下のようになっています。

resource "misskey_note" "test" {
  text             = <<-EOT
        テスト1
        テスト2
        テスト3
    EOT
  visibility       = "specified"
  visible_user_ids = []
}

このディレクトリでterraform applyを実行し、Misskeyのノートが作成されることを確認できました。

Terraform

Posted by maeda6uiui