はじめてのTerraform Provider

この記事はエイチーム引越し侍 / エイチームコネクトの社員による、[Ateam Hikkoshi samurai Inc.× Ateam Connect Inc. Advent Calendar 2021](https://qiita.com/advent-calendar/2021/hikkoshizamurai-connect) 23日目の記事です。
本日は引越し侍のインフラエンジニアの@dd511805が担当します！

# はじめに
引越し侍ではインフラの構築にTerraformを用いています。多くのサービスはAWS上にあるので、CloudFormationやAWS CDKの
導入を検討したこともありますが、最近ではGCPのような別のクラウド、あるいはFastlyのようなCDNサービスを利用すること
もあり、多くのサービスにProviderが対応しているTerraformはやはり便利だと改めて感じています。
とはいえ、全てのサービスにProviderが対応しているわけではなく、Terraformのコード管理に慣れきってしまっていると
手作業でのオペレーションが煩雑に感じてきます。
今回はTerraform Providerの自作の方法を学び、将来的には内製のシステム等のProviderを構築するきっかけを得たいと思います。

# Terraform Providerを作成する方法
Terraform Providerを作成する方法は[Plugin Developmen](https://www.terraform.io/plugin)に記載されていますが
大きく分けて２つの方法があります。
- [Terraform Plugin SDKv2](https://www.terraform.io/plugin/sdkv2/sdkv2-intro)
- [Terraform Plugin Framework](https://www.terraform.io/plugin/framework)
Terraform Plugin SDKv2は現在よく使われている手法でTerraform Plugin Frameworkはまだ開発中のため、
Terraform v1.0以降でしか動作しません。後方互換性を考えると主要なProviderがTerraform Plugin Frameworkに移るのは
相当先のようにも思えますが、新しく作る分には古いTerraformを使うこともないので、Terraform Plugin Frameworkを
用いてProviderを作成してみます。

# Terraform Plugin Frameworkのキーコンセプト
Terraform Plugin Frameworkには以下の４つのキーコンセプトがあります。
- [Providers](https://www.terraform.io/plugin/framework/providers)
- [Schemas](https://www.terraform.io/plugin/framework/schemas)
- [Resources](https://www.terraform.io/plugin/framework/resources)
- [Data Sources](https://www.terraform.io/plugin/framework/data-sources)
Terraform Plugin SDKv2のキーコンセプトとは異なる部分もあり、かなり構造が細かくなった印象があります。

# 自作Providerの作成
[Implement Create and Read with the Terraform Plugin Framework](https://learn.hashicorp.com/tutorials/terraform/plugin-framework-create)にProviderの
作り方のチュートリアルがあるので、これを参考にしながらまずは何もしないProviderを作成して構成を学びます。

```
ubuntu:~/terraform-provider-mybotip$ tree
.
├── go.mod
├── go.sum
├── main.go
├── mybotip
│   └── provider.go
└── mybotpkg
    └── client.go
```

上記のようなディレクトリ構成を作成して、main.goに以下のような記述を行います。
Nameでprovider名の指定を行うことmybotipディレクトリのprovider.goを読み込んで、
mybotipのNewを呼び出しています。

```main.go
package main

import (
	"context"
	"github.com/hashicorp/terraform-plugin-framework/tfsdk"
	"terraform-provider-mybotip/mybotip"
)

func main() {
	tfsdk.Serve(context.Background(), mybotip.New, tfsdk.ServeOpts{
		Name: "mybotip",
	})
}

```
mybotip.Newはtfsdk.Providerのインタフェースに沿った実装を返す必要があるので、GetSchema、Configure、GetResources、GetDataSources
のメソッドを実装します。GetSchemaはAttributesを返す必要があるので、適用なAttributesを定義していますが、GetResources、GetDataSources
はまだ。Resource、DataSourceともに実装していないので、実質的には何も返していません。
providerのstructにはclientという属性を定義していますが、これはResourceやDataSourceから呼び出される処理を実装する場所になります。

```mybotip/provider.go
package mybotip

import (
	"context"
	"os"

	"github.com/hashicorp/terraform-plugin-framework/diag"
	"github.com/hashicorp/terraform-plugin-framework/tfsdk"
	"github.com/hashicorp/terraform-plugin-framework/types"
	"terraform-provider-mybotip/mybotpkg"
)

var stderr = os.Stderr

func New() tfsdk.Provider {
	return &provider{}
}

type provider struct {
	configured bool
	client     *pkg.Client
}

// GetSchema
func (p *provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) {
	return tfsdk.Schema{
		Attributes: map[string]tfsdk.Attribute{
			"name": {
				Type:     types.StringType,
				Optional: true,
				Computed: true,
			},
		},
	}, nil
}

// Provider schema struct
type providerData struct {
	Name types.String `tfsdk:"name"`
}

func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, resp *tfsdk.ConfigureProviderResponse) {
	// Retrieve provider data from configuration
	var config providerData
	diags := req.Config.Get(ctx, &config)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	p.configured = true
}

func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) {
	return map[string]tfsdk.ResourceType{
	}, nil
}

func (p *provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourceType, diag.Diagnostics) {
	return map[string]tfsdk.DataSourceType{
	}, nil
}
```
providerから呼び出すClientは以下のような構造にしておきます。

```mybotpkg/client.go
package mybotpkg

// Client -
type Client struct {
}
```

これで何もしないProviderが完成したので、ビルドをします。

```
go build -o terraform-provider-mybotip_v0.0.1
mkdir -p ~/.terraform.d/plugins/local/edu/mybotip/0.0.1/linux_amd64
mv terraform-provider-mybotip_v0.0.1 ~/.terraform.d/plugins/local/edu/mybotip/0.0.1/linux_amd64
```

# 自作Providerの実行
前のステップでビルドしたProviderを実際に使えるか試してみます。
以下のようにproviderを使う記述をします。

```main.tf
terraform {
  required_providers {
    mybotip = {
      source  = "local/edu/mybotip"
      version = "0.0.1"
    }
  }
  required_version = "~> 1.1.0"
}

provider "mybotip" {
}

```

そのまま実行すると、以下のようなエラーが発生してしまうので、-plugin-dirでpluginの場所を絶対パスで
指定してinitとapplyを行います。

```
ubuntu:~/terraform-provider-mybotip/example$ terraform init -plugin-dir=/home/ubuntu/.terraform.d/plugins

Initializing the backend...

Initializing provider plugins...
- Finding local/edu/mybotip versions matching "0.0.1"...
- Installing local/edu/mybotip v0.0.1...
- Installed local/edu/mybotip v0.0.1 (unauthenticated)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

ubuntu:~/terraform-provider-mybotip/example$ terraform apply
No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed
```

無事にApplyまで完成したので、自作のProviderを使えるようになりました。

# DataResourceの作成
Providerを作ることが出来たので、次はDataResourceを作成してみます。
Googleが公開している[Google BotのIP](https://developers.google.com/search/apis/ipranges/googlebot.json)を返すDataResourceを
作成してみます。
IPを取得する部分はclient.goに実装していくので、client.goを以下のように変更します。

```mybotpkg/client.go
package mybotpkg

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"time"
)

// Client -
type Client struct {
	HostURL    string
	HTTPClient *http.Client
}

const HostURL string = "https://developers.google.com/search/apis/ipranges/googlebot.json"

// Client -

// NewClient -
func NewClient() (*Client, error) {
	c := Client{
		HTTPClient: &http.Client{Timeout: 10 * time.Second},
		HostURL: HostURL,
	}

	return &c, nil
}

type Prefixes []struct {
	Ipv6Prefix string `json:"ipv6Prefix,omitempty"`
	Ipv4Prefix string `json:"ipv4Prefix,omitempty"`
}

type AutoGenerated struct {
	CreationTime string `json:"creationTime"`
	Prefixes
}

func (c *Client) GetIPs() ([]string, error) {
	req, err := http.NewRequest("GET", c.HostURL, nil)
	res, err := c.HTTPClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}

    jsonBytes := ([]byte)(body)
    data := new(AutoGenerated)

    if err := json.Unmarshal(jsonBytes, data); err != nil {
        return nil,  fmt.Errorf("JSON Unmarshal error: %s", err)
    }

	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("status: %d, body: %s", res.StatusCode, body)
	}

	var items []string
	for _, item := range data.Prefixes {
		if item.Ipv4Prefix != "" {
			items = append(items, item.Ipv4Prefix)
		}
	}

	return items, err
}

```
client.goのGetIPsでIPのレンジをStringのスライスで返すことが出来るようになったので、これを利用するdatasource_mybotip.goを作成します。
tfsdk.DataSourceTypeのインターフェースに沿って実装を行う形で、GetSchema、NewDataSource、Readのメソッドを実装します。


```mybotip/datasource_mybotip.go
package mybotip

import (
	"context"
	"fmt"

	"github.com/hashicorp/terraform-plugin-framework/diag"
	"github.com/hashicorp/terraform-plugin-framework/tfsdk"
	"github.com/hashicorp/terraform-plugin-framework/types"
)

type dataSourceMybotIPsType struct{}

func (r dataSourceMybotIPsType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) {
	return tfsdk.Schema{
		Attributes: map[string]tfsdk.Attribute{
			"ips": {
				Computed: true,
				Attributes: tfsdk.ListNestedAttributes(map[string]tfsdk.Attribute{
					"iprange": {
						Type:     types.StringType,
						Computed: true,
					},
				}, tfsdk.ListNestedAttributesOptions{}),
			},
		},
	}, nil
}

func (r dataSourceMybotIPsType) NewDataSource(ctx context.Context, p tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) {
	return dataSourceMybotIPs{
		p: *(p.(*provider)),
	}, nil
}

type dataSourceMybotIPs struct {
	p provider
}

func (r dataSourceMybotIPs) Read(ctx context.Context, req tfsdk.ReadDataSourceRequest, resp *tfsdk.ReadDataSourceResponse) {
	var resourceState struct {
		IPs []MybotIP `tfsdk:"ips"`
	}
   
	ips, err := r.p.client.GetIPs()
	if err != nil {
		resp.Diagnostics.AddError(
			"Error retrieving mybotip",
			err.Error(),
		)
		return
	}
	fmt.Fprintf(stderr, "[DEBUG]-ips:%+v", ips)
	
	for _, ip := range ips {
		c := MybotIP{
			Iprange:        types.String{Value: ip},
		}

		resourceState.IPs = append(resourceState.IPs, c)
	}

	fmt.Fprintf(stderr, "[DEBUG]-Resource State:%+v", resourceState)

	diags := resp.State.Set(ctx, &resourceState)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}	
}
```
DataSourceのスキーマに対応したstructをmodels.goに定義します。

```mybotip/models.go
package mybotip

import (
	"github.com/hashicorp/terraform-plugin-framework/types"
)

type MybotIP struct {
	Iprange       types.String `tfsdk:"iprange"`
}

```

最後にprovider.goのGetDataSourcesを今回作成したdatasource_mybotip.goを参照するように変更します。

```mybotip/provider.go
...
func (p *provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourceType, diag.Diagnostics) {
	return map[string]tfsdk.DataSourceType{
		"mybotip_ip": dataSourceMybotIPsType{},
	}, nil
}
```

これをビルドすれば完成です。

# DataResourceの動作確認
前のステップで実装したDataResource mybotip_ipを利用するためにはmain.tfに以下の記述を加えます。

```main.tf
...
data "mybotip_ip" "this" {
}
```

terraform applyを行ってapplyが正常に終了したら、terraform consoleでDatResourceを確認します。

```
ubuntu:~/terraform-provider-mybotip/example$ terraform console
> data.mybotip_ip.this
{
  "ips" = tolist([
    {
      "iprange" = "66.249.64.0/27"
    },
    {
      "iprange" = "66.249.64.128/27"
    },
      "iprange" = "66.249.75.224/27"
    },
...
    {
      "iprange" = "66.249.79.96/27"
    },
  ])
}
>  
```

無事にGoogle BotのIPをDatResourceで取得することが出来るようになりました。

# まとめ
Terraform Plugin Frameworkを用いたProviderとDataResourceの作り方を見てきました。Terraform Plugin Frameworkを
用いることで、Terraform Pluginの構造にそこまで精通していなくても、必要なインターフェースを実装していくだけで、
Providerを作成することが出来るようになりました。Terraform Plugin Frameworkはまだ開発中ということで、今後も
大きな変更があるかもしれませんが、これからTerraform Providerを作成することを考えるときには選択肢の一つとして
検討してみるべきだと感じました。


# 明日
[Ateam Hikkoshi samurai Inc.× Ateam Connect Inc. Advent Calendar 2021](https://qiita.com/advent-calendar/2021/hikkoshizamurai-connect) 23日目の記事は、いかがでしたでしょうか。
明日の @kaitat の記事もお楽しみに。