Custom Terraform Provider はじめて作成してみた
この記事は 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-sdk と Terraform 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/notion
Provider に関してはローカルで開発中のものを使用するようになります。
terraform init
不要でterraform plan
やterraform 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 = ¬ionProvider{}
)
// New is a helper function to simplify provider server and testing implementation.
func New(version string) func() provider.Provider {
return func() provider.Provider {
return ¬ionProvider{
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 plan
やterraform 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 で下記のようなデータベースを作成したので、こちらを読み取りたいと思います。
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 = ¬ionDataSource{}
_ datasource.DataSourceWithConfigure = ¬ionDataSource{}
)
// NewNotionDataSource is a helper function to simplify the provider implementation.
func NewNotionDataSource() datasource.DataSource {
return ¬ionDataSource{}
}
// 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(¬ionResp); 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