🥢

Custom Terraform Provider はじめて作成してみた

2024/12/20に公開

この記事は Magic Moment Advent Calendar 2024 15 日目の記事です。

Magic Moment ソフトウェアエンジニアの scent-y です。

弊社では GCP のリソース管理などで Terraform を利用しています。Terraform を利用する中で、公式で対応されてない外部リソースを管理したくなった場合はどうすれば良いのだろう?とふと疑問を抱きました。

そんな中、Terraform では Provider を自作できる仕組みがあることを知りました。実際に作成してみることで、Terraform の理解につながったり、管理したいリソースの Provider が存在しない場合の選択肢が広がるかなと思い、入門してみた内容になります。

普段から利用していて自分にとって身近なツールである、Notion の Provider を作成してみます。

公式のチュートリアルを Notion に置き換えただけの内容ですが、これから Custom Terraform Provider を作成してみたい方の参考になる部分が少しでもあれば幸いです。

Provider の開発をサポートする SDK として terraform-plugin-sdkTerraform Plugin Framework が HashiCorp 社から提供されています。
新しい Provider の作成では Framework の使用が推奨されているので、Terraform Plugin Framework を使用します。

本記事ではテスト、Terraform Registry への公開方法は扱いません。

ローカルで検証するための準備

Terraform はterraform initコマンドを実行すると Provider のインストールと検証を行います。通常は Provider Registry または Local Registry から Provider をダウンロードします。
ただ、Provider を開発している場合はローカルでビルドしたバージョンを検証したい場合があり、そのようなケースに対応するため、Terraform の通常の検証プロセスをバイパスする方法が提供されています。

.terraformrcファイルにdev_overridesブロックを追加することで可能になります。

まず下記を実行しパスを取得します。

go env GOBIN

~/.terraformrc に下記を追加します。GOBIN PATHは上記で取得したパスを指定します。

provider_installation {

  dev_overrides {
      "registry.terraform.io/scent-y/notion" = "<GOBIN PATH>"
  }

  # For all other providers, install them directly from their origin provider
  # registries as normal. If you omit this, Terraform will _only_ use
  # the dev_overrides block, and so no other providers will be available.
  direct {}
}

上記の設定をすることで、registry.terraform.io/scent-y/notionProvider に関してはローカルで開発中のものを使用するようになります。
terraform init不要でterraform planterraform applyを実行できたり、バージョン番号の検証などをバイパスできるようになります。

API クライアントの初期化

Provider で使う Notion の API クライアントを初期化します。

terraform-provider-scaffolding-framework リポジトリを Clone します。

ルートディレクトリの main.go の Address を変更します。

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/scent-y/notion", // ここを変更
		Debug:   debug,
	}

	err := providerserver.Serve(context.Background(), provider.New(version), opts)

	if err != nil {
		log.Fatal(err.Error())
	}
}

Notion API Client 用のコードをinternal/client/notion.goに追加します。

package client

import (
	"errors"
	"net/http"
)

type NotionClient struct {
	Token     string
	Version   string
	BaseURL   string
	APIClient *http.Client
}

func NewNotionClient(token, version string) (*NotionClient, error) {
	if token == "" {
		return nil, errors.New("missing Notion API Token")
	}

	defaultVersion := "2022-06-28"
	if version == "" {
		version = defaultVersion
	}

	return &NotionClient{
		Token:     token,
		Version:   version,
		BaseURL:   "https://api.notion.com/v1",
		APIClient: http.DefaultClient,
	}, nil
}

func (c *NotionClient) DoRequest(req *http.Request) (*http.Response, error) {
	req.Header.Set("Authorization", "Bearer "+c.Token)
	req.Header.Set("Notion-Version", c.Version)

	return c.APIClient.Do(req)
}

Terraform では、.tf ファイルの provider ブロックに設定された情報をもとに、外部サービスとの通信を行います。Provider の開発では、まず Provider ブロックのスキーマを開発していきます。

internal/provider/provider.goを下記のように修正します。

本来ならもっと秘匿性高くトークンを管理すべきですが、下記では簡易的な実装として環境変数にトークンを格納しています。

package provider

import (
	"context"
	"os"

	notionclient "terraform-provider-notion/internal/client"

	"github.com/hashicorp/terraform-plugin-framework/datasource"
	"github.com/hashicorp/terraform-plugin-framework/path"
	"github.com/hashicorp/terraform-plugin-framework/provider"
	"github.com/hashicorp/terraform-plugin-framework/provider/schema"
	"github.com/hashicorp/terraform-plugin-framework/resource"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

// Ensure the implementation satisfies the expected interfaces.
var (
	_ provider.Provider = &notionProvider{}
)

// New is a helper function to simplify provider server and testing implementation.
func New(version string) func() provider.Provider {
	return func() provider.Provider {
		return &notionProvider{
			version: version,
		}
	}
}

// notionProvider is the provider implementation.
type notionProvider struct {
	// version is set to the provider version on release, "dev" when the
	// provider is built and ran locally, and "test" when running acceptance
	// testing.
	version string
}

// Metadata returns the provider type name.
func (p *notionProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
	resp.TypeName = "notion"
	resp.Version = p.version
}

// Schema defines the provider-level schema for configuration data.
func (p *notionProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"token": schema.StringAttribute{
				Required:    true,
				Sensitive:   true,
				Description: "Notion API integration token",
			},
			"notion_version": schema.StringAttribute{
				Optional:    true,
				Description: "Notion Version",
			},
		},
	}
}

// notionProviderModel maps provider schema data to a Go type.
type notionProviderModel struct {
	Token         types.String `tfsdk:"token"`
	NotionVersion types.String `tfsdk:"notion_version"`
}

// Configure prepares a notion API client for data sources and resources.
func (p *notionProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
	var config notionProviderModel
	diags := req.Config.Get(ctx, &config)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	if config.Token.IsUnknown() {
		resp.Diagnostics.AddAttributeError(
			path.Root("token"),
			"Unknown Notion API Token",
			"The provider cannot create the Notion API client as there is an unknown configuration value for the Notion API token. "+
				"Either target apply the source of the value first, set the value statically in the configuration, or use the NOTION_TOKEN environment variable.",
		)
	}

	if config.NotionVersion.IsUnknown() {
		resp.Diagnostics.AddAttributeError(
			path.Root("notion_version"),
			"Unknown Notion API Version",
			"The provider cannot create the Notion API client as there is an unknown configuration value for the Notion API version. "+
				"Either target apply the source of the value first, set the value statically in the configuration, or use the NOTION_VERSION environment variable.",
		)
	}

	if resp.Diagnostics.HasError() {
		return
	}

	token := os.Getenv("NOTION_TOKEN")
	version := os.Getenv("NOTION_VERSION")

	if !config.Token.IsNull() {
		token = config.Token.ValueString()
	}

	if !config.NotionVersion.IsNull() {
		version = config.NotionVersion.ValueString()
	}

	if token == "" {
		resp.Diagnostics.AddAttributeError(
			path.Root("token"),
			"Missing Notion API Token",
			"The provider cannot create the Notion API client as there is a missing or empty value for the Notion API token. "+
				"Set the token value in the configuration or use the NOTION_TOKEN environment variable. "+
				"If either is already set, ensure the value is not empty.",
		)
	}

	if version == "" {
		// set default value
		version = "2022-06-28"
	}

	if resp.Diagnostics.HasError() {
		return
	}

	client, err := notionclient.NewNotionClient(token, version)
	if err != nil {
		resp.Diagnostics.AddError(
			"Unable to Create Notion API Client",
			"An unexpected error occurred when creating the Notion API client. "+
				"If the error is not clear, please contact the provider developers.\n\n"+
				"Notion Client Error: "+err.Error(),
		)
		return
	}

	resp.DataSourceData = client
	resp.ResourceData = client
}

// DataSources defines the data sources implemented in the provider.
func (p *notionProvider) DataSources(_ context.Context) []func() datasource.DataSource {
	return []func() datasource.DataSource{
		NewNotionDataSource,
	}
}

// Resources defines the resources implemented in the provider.
func (p *notionProvider) Resources(_ context.Context) []func() resource.Resource {
	return nil
}

Notion API Client で必須な、integration token をRequired: trueにしています。
必須な Attribute にすることで、Attribute が指定されてないときにterraform planterraform applyの実行を失敗させることができます。

トークンを指定せずに実行すると、下記のように指定したエラーメッセージが出力されます。

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Missing Notion API Token
│ 
│   with provider["registry.terraform.io/scent-y/notion"],
│   on <input-prompt> line 1:
│   (source code not available)
│ 
│ The provider cannot create the Notion API client as there is a missing or empty value for the Notion API token. Set the token
│ value in the configuration or use the NOTION_TOKEN environment variable. If either is already set, ensure the value is not empty.
╵

data source の実装

外部リソースを Terraform で読み取り専用で扱えるようにする、data source を実装します。

Notion で下記のようなデータベースを作成したので、こちらを読み取りたいと思います。

Notion データベース

internal/provider/notion_data_source.go を追加します。

package provider

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"

	"terraform-provider-notion/internal/client"

	"github.com/hashicorp/terraform-plugin-framework/datasource"
	"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

// Ensure the implementation satisfies the expected interfaces.
var (
	_ datasource.DataSource              = &notionDataSource{}
	_ datasource.DataSourceWithConfigure = &notionDataSource{}
)

// NewNotionDataSource is a helper function to simplify the provider implementation.
func NewNotionDataSource() datasource.DataSource {
	return &notionDataSource{}
}

// notionDataSource is the data source implementation.
type notionDataSource struct {
	client *client.NotionClient
}

func (d *notionDataSource) Configure(ctx context.Context, request datasource.ConfigureRequest, response *datasource.ConfigureResponse) {
	if request.ProviderData == nil {
		return
	}

	notionClient, ok := request.ProviderData.(*client.NotionClient)
	if !ok {
		response.Diagnostics.AddError(
			"Unexpected Data Source Configure Type",
			fmt.Sprintf("Expected *client.NotionClient, got: %T. Please report this issue to the provider developers.", request.ProviderData),
		)

		return
	}

	d.client = notionClient
}

// Metadata returns the data source type name.
func (d *notionDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
	resp.TypeName = req.ProviderTypeName + "_database"
}

// Schema defines the schema for the data source.
func (d *notionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"database_id": schema.StringAttribute{
				Required:    true,
				Description: "The ID of the Notion database",
			},
			"database": schema.SingleNestedAttribute{
				Computed: true,
				Attributes: map[string]schema.Attribute{
					"id": schema.StringAttribute{
						Computed: true,
					},
					"created_time": schema.StringAttribute{
						Computed: true,
					},
					"last_edited_time": schema.StringAttribute{
						Computed: true,
					},
					"title": schema.ListNestedAttribute{
						Computed: true,
						NestedObject: schema.NestedAttributeObject{
							Attributes: map[string]schema.Attribute{
								"type": schema.StringAttribute{
									Computed: true,
								},
								"plain_text": schema.StringAttribute{
									Computed: true,
								},
							},
						},
					},
					"properties": schema.MapNestedAttribute{
						Computed: true,
						NestedObject: schema.NestedAttributeObject{
							Attributes: map[string]schema.Attribute{
								"id": schema.StringAttribute{
									Computed: true,
								},
								"name": schema.StringAttribute{
									Computed: true,
								},
								"type": schema.StringAttribute{
									Computed: true,
								},
							},
						},
					},
					"url": schema.StringAttribute{
						Computed: true,
					},
					"archived": schema.BoolAttribute{
						Computed: true,
					},
					"is_inline": schema.BoolAttribute{
						Computed: true,
					},
					"public_url": schema.StringAttribute{
						Computed: true,
					},
				},
			},
		},
	}
}

// notionDatabaseDataSourceModel maps the data source schema data.
type notionDatabaseDataSourceModel struct {
	DatabaseID string         `tfsdk:"database_id"`
	Database   *databaseModel `tfsdk:"database"`
}

// databaseModel maps database schema data.
type databaseModel struct {
	ID             types.String             `tfsdk:"id"`
	CreatedTime    types.String             `tfsdk:"created_time"`
	LastEditedTime types.String             `tfsdk:"last_edited_time"`
	Title          []titleModel             `tfsdk:"title"`
	Properties     map[string]propertyModel `tfsdk:"properties"`
	URL            types.String             `tfsdk:"url"`
	Archived       types.Bool               `tfsdk:"archived"`
	IsInline       types.Bool               `tfsdk:"is_inline"`
	PublicURL      types.String             `tfsdk:"public_url"`
}

// titleModel maps database title data
type titleModel struct {
	Type      types.String `tfsdk:"type"`
	PlainText types.String `tfsdk:"plain_text"`
}

// propertyModel maps database property data
type propertyModel struct {
	ID   types.String `tfsdk:"id"`
	Name types.String `tfsdk:"name"`
	Type types.String `tfsdk:"type"`
}

// Read refreshes the Terraform state with the latest data.
func (d *notionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
	var state notionDatabaseDataSourceModel

	// Get current state
	diags := req.Config.Get(ctx, &state)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	// Create request
	url := fmt.Sprintf("%s/databases/%s", d.client.BaseURL, state.DatabaseID)
	httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		resp.Diagnostics.AddError(
			"Unable to Create Request",
			"An unexpected error occurred while creating the request: "+err.Error(),
		)
		return
	}

	// Execute request
	response, err := d.client.DoRequest(httpReq)
	if err != nil {
		resp.Diagnostics.AddError(
			"Unable to Read Notion Database",
			"An unexpected error occurred while making the request: "+err.Error(),
		)
		return
	}

	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		resp.Diagnostics.AddError(
			"Notion API Error",
			fmt.Sprintf("Received status code %d from Notion API", response.StatusCode),
		)
		return
	}

	// Parse response
	var notionResp map[string]interface{}
	if err := json.NewDecoder(response.Body).Decode(&notionResp); err != nil {
		resp.Diagnostics.AddError(
			"Unable to Parse Response",
			"An unexpected error occurred while parsing the response: "+err.Error(),
		)
		return
	}

	// Map response to state
	state.Database = &databaseModel{
		ID:             types.StringValue(getString(notionResp, "id")),
		CreatedTime:    types.StringValue(getString(notionResp, "created_time")),
		LastEditedTime: types.StringValue(getString(notionResp, "last_edited_time")),
		URL:            types.StringValue(getString(notionResp, "url")),
		Archived:       types.BoolValue(getBool(notionResp, "archived")),
		IsInline:       types.BoolValue(getBool(notionResp, "is_inline")),
		PublicURL:      types.StringValue(getString(notionResp, "public_url")),
		Title:          make([]titleModel, 0),
		Properties:     make(map[string]propertyModel),
	}

	// Map title
	if titleData, ok := notionResp["title"].([]interface{}); ok && titleData != nil {
		for _, t := range titleData {
			if titleMap, ok := t.(map[string]interface{}); ok && titleMap != nil {
				state.Database.Title = append(state.Database.Title, titleModel{
					Type:      types.StringValue(getString(titleMap, "type")),
					PlainText: types.StringValue(getString(titleMap, "plain_text")),
				})
			}
		}
	}

	// Map properties
	if propsData, ok := notionResp["properties"].(map[string]interface{}); ok && propsData != nil {
		for key, prop := range propsData {
			if propMap, ok := prop.(map[string]interface{}); ok && propMap != nil {
				state.Database.Properties[key] = propertyModel{
					ID:   types.StringValue(getString(propMap, "id")),
					Name: types.StringValue(getString(propMap, "name")),
					Type: types.StringValue(getString(propMap, "type")),
				}
			}
		}
	}

	// Set state
	diags = resp.State.Set(ctx, &state)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}
}

func getString(m map[string]interface{}, key string) string {
	if str, ok := m[key].(string); ok {
		return str
	}
	return ""
}

func getBool(m map[string]interface{}, key string) bool {
	if b, ok := m[key].(bool); ok {
		return b
	}
	return false
}

tfsdk タグは Go の構造体のフィールドと .tf ファイルの設定項目をマッピングするために使用します。

Read メソッドでは、スキーマに基づいて Terraform state を更新します。
Notion API をコールして Notion データベースの情報を取得し、取得したデータを Terraform state に保存してます。

data source の実装が期待通りになっているか、検証してみます。

examples/notion/main.tf を追加します。

terraform {
  required_providers {
    notion = {
      source = "scent-y/notion"
    }
  }
}

provider "notion" {
  notion_version = "2022-06-28"
}

data "notion_database" "example" {
  database_id = "" // Notion の Database ID を指定する
}

output "database_info" {
  value = data.notion_database.example
}

terraform planを実行します。
※実行結果から id 情報などは省いています。

% terraform plan    
╷
│ Warning: Provider development overrides are in effect
│ 
│ The following provider development overrides are set in the CLI configuration:
│  - scent-y/notion in /your-path/go/bin
│ 
│ The behavior may therefore not match any released version of the provider and applying changes may cause the state to
│ become incompatible with published releases.
╵

data.notion_database.example: Reading...
data.notion_database.example: Read complete after 1s

Changes to Outputs:
  + database_info = {
      + database    = {
          + archived         = false
          + created_time     = "2024-12-18T16:54:00.000Z"
          + id               = "xxx"
          + is_inline        = false
          + last_edited_time = "2024-12-18T16:56:00.000Z"
          + properties       = {
              + column1 = {
                  + id   = "title"
                  + name = "column1"
                  + type = "title"
                }
              + column2 = {
                  + id   = "xxx"
                  + name = "column2"
                  + type = "rich_text"
                }
            }
          + public_url       = ""
          + title            = [
              + {
                  + plain_text = "example-database"
                  + type       = "text"
                },
            ]
          + url              = "https://www.notion.so/xxx"
        }
      + database_id = "xxx"
    }

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you
run "terraform apply" now.

Notion のデータベースを取得できていることを確認できました!

最後に

Provider 作成のほんの導入部分をやってみただけの紹介でしたが、自分が作成した Provider で外部リソースを管理できるようになる体験は、楽しいの一言に尽きるなと感じました。

ここまで読んでいただいてありがとうございました。

次回のアドベントカレンダーtnegishiの「Next.js のリリースは追っておこう」 です。お楽しみに!

Discussion