Terraform Provider実装 入門(2): リソース実装 基礎

f:id:febc_yamamoto:20180914185855p:plain

目次(未確定)

前回はCustom Providerとして最低限の実装を行いTerraformから利用できるようにするところまででした。
今回は引き続きTerraformでのCustom Providerの実装の基礎的な部分について扱います。
前回作成したterraform-provider-minimumにリソース操作のための実装を追加していきます。

プロバイダーで扱うリソースの実装

それでは早速minimumプロバイダーで扱うリソースを実装してみます。

minimumプロバイダーの仕様

Terraformプロバイダーの多くは対象プラットフォーム(例:AWS/Azure/GCP)が提供するAPIを用いてプラットフォーム上のリソースに対するCRUD操作を行うという形で実装されますが、 minimumプロバイダーはサンプル実装ということで簡易的にローカルマシン上にファイルを作成するように実装します。

ここでは例としてminimumプロバイダーに対しbasicというリソースを実装してみます。
以下のようにnameという属性一つだけをもつシンプルなリソースです。

resource minimum_basic "example" {
  name = "foobar"
}

terraform applyを実行するとカレントディレクトリに以下のようなJSONファイルが作成されます。

{
  "Name": "foobar"
}

それでは早速リソースの実装を行なっていきます。

なお、basicリソースを実装したソース一式は以下に置いています。

github.com

前回ソースコードをクローンされた方はbasicブランチをチェックアウトしておいてください。

$ cd $GOPATH/github.com/yamamoto-febc/terraform-provider-minimum
$ git checkout basic

basicリソースの実装

リソース実装の手順は以下の通りです。

  • (1) *schema.Resourceを返すfuncを作成
  • (2) Provider()に(1)で実装したfuncを追記

順に見ていきます。

(1) *schema.Resourceを返すfuncを作成

schema.Resourceとは、リソースに対するCRUDを行うfuncや入出力項目のスキーマ定義を持つ構造体です。
ソース: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L23-L137

リソースとしての最低限の実装は以下の通りです。

package minimum

import  "github.com/hashicorp/terraform/helper/schema"

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        // CRUD操作用のfuncをそれぞれ指定
        Create: resourceMinimumBasicCreate,
        Read:   resourceMinimumBasicRead,
        Update: resourceMinimumBasicUpdate, // 省略可
        Delete: resourceMinimumBasicDelete,
     
        Schema: map[string]*schema.Schema{
            // ここに入出力項目を定義
        },
    }
}

func resourceMinimumBasicCreate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceMinimumBasicRead(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceMinimumBasicUpdate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceMinimumBasicDelete(d *schema.ResourceData, meta interface{}) error {
    return nil
}

schema.Resourceはいくつかのフィールドを持っていますが、リソースとして動作させるには最低限以下の項目をセットする必要があります。

  • Create: リソース作成用のfunc
  • Read: リソース参照用のfunc
  • Update: リソース更新用のfunc(特殊なリソースでは省略可)
  • Delete: リソース削除用のfunc
  • Schema: 入出力項目の定義

CRUD用のfuncはfunc (*schema.ResourceData, interface{}) errorというシグニチャを持ちます。
このうちUpdateは省略可能ですが、省略するにはいくつか条件がありますので次回以降の記事で改めて解説します。
それぞれのfuncの書き方にはお作法がありますので後ほどCRUDそれぞれについて見ていきます。

Schemaにはこのリソースで扱う入出力項目をmap[string]*schema.Schemaとして記述します。
mapのキーが項目名となり、項目のデータ型や制約などを*schema.Schemaとして記述します。

Schemaの定義

今回のbasicリソースではnameという文字列型の項目を一つだけ定義しておきます。

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "name": {
                Type:     schema.TypeString,
                Required: true,
            },
        },
    }
}

*schema.Schemaには他にもいくつかのフィールドがありますが、それらは次回以降に扱います。

Createの実装

続いてCreate用のfuncを実装します。
Createでは以下の処理を行う必要があります。

  • tfファイルからの入力を取得
  • 各プラットフォームのAPIなどを用いて実リソースの作成
  • *schema.ResourceDataSetId()を呼び出してリソースIDを設定
  • 必要に応じて*schema.ResourceDataSet()を呼び出してリソースの値を設定

basicリソースでは以下のように実装します。

var client = state.NewDriver("basic")

func resourceMinimumBasicCreate(d *schema.ResourceData, meta interface{}) error {
    // tfファイルからの入力を取得
    name := d.Get("name").(string)

    // プラットフォームのAPIなどを用いて実リソースの作成
    // (ここではローカルマシン上にJSONファイルを保存)
    value := &state.BasicValue{
        Name: name,
    }
    id, err := client.Create(value) // 実リソース作成 - リソースのIDを戻り値として受け取る
    if err != nil {
        return err
    }

    // リソースIDを設定
    d.SetId(id)
 
    // リソースの値を設定
    return resourceMinimumBasicRead(d, meta)
}

第1引数のd*schema.ResourceData型となっています。
これはリソースに対する入出力値を扱う構造体で、tfファイルからの入力値の取得やTerraformが管理するリソースの値=Stateへのアクセスなどを担当します。
TerraformでのStateについては以下を参照してください。

www.terraform.io

第2引数のmetaAPIキーといったプロバイダー固有の設定を各リソースに受け渡すためのものです。
通常APIクライアントなどが設定されています。
今回は利用していませんが、次回の記事でこの引数を利用するようにリファクタしてみます。

tfファイルからの入力値の取得

まずはd.Get("項目名")でtfファイルからの入力値を取得しています。戻り値はinterface{}になっていますので、適切な型にキャストします。

プラットフォームのAPIを利用して実リソースの作成

入力値が出揃ったらプラットフォームのAPIを利用して実リソースの作成を行います。
この際にリソースごとに一意なIDを発行する必要があります。

今回はサンプルとしてプラットフォームのAPIを呼び出す代わりにローカルマシン上にJSONファイルとして保存しておくようにしています。
実際の処理はstateパッケージに実装していますので興味のある方はソースを眺めてみてください。
(値をinterface{}型として受け取り、JSONにして保存、IDはランダムな数値を返す実装です)

リソースIDの設定

実リソースを作成しIDが発行されたらd.SetId()を呼び出してリソースIDの設定を行います。
Terraformはこのfuncを抜けた後にd.SetId()を通じてリソースIDが設定されていか判定し、設定されている場合はリソースの持つ値をStateに保存します。
(特にバックエンドの設定をしていない場合はカレントディレクトリにterraform.tfstateというファイルが作成されます)

リソースの値の設定

最後にd.Set()を呼び出してリソースの値をキーごとに設定していきます。 設定した値は最終的にStateに保存されます。 (なお、d.SetId()d.Set()の順番はどちらが先でもOKです)

ここで直接d.Set()を呼んでも良いのですが、後ほどReadを実行するときにもd.Set()を実行することになりますので、実装の重複を避けるためにRead用のfuncを直接呼び出すようにしています。

Readの実装

続いてReadを実装していきます。
Readでは以下の処理を行う必要があります。

  • *schema.ResourceDataId()を呼び出してリソースのIDを取得
  • 取得したIDを用いて各プラットフォームのAPIなどを呼び出し実リソースのデータを参照
  • *schema.ResourceDataSet()を呼び出してリソースの値を設定

一言で言うと、リソースのIDを用いて実リソースの最新リソースを参照し、リソースの値を設定を行います。

basicリソースでは以下のように実装します。

func resourceMinimumBasicRead(d *schema.ResourceData, meta interface{}) error {
    // リソースのIDを取得
    id := d.Id()

    // IDを用いてプラットフォームのAPIを呼び出し、実リソースを参照する        
    data, err := client.Read(id)
    if err != nil {
        // 実リソースが見つからなかったら
        if state.IsStateNotFoundError(err) {
            // *Point* IDを空にしてnilを返す
            d.SetId("")
            return nil
        }
        return err
    }
    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    // 実リソースの最新の情報をリソースデータに設定
    d.Set("name", value.Name) // nolint

    return nil
}
実装時の注意点: 実リソースが存在しなかった場合の対応

ポイントは実リソースが見つからなかった場合の処理です。
IDを元にプラットフォーム上を検索してみたけど見つからなかったと言う場合には d.SetId(""")を呼びリソースIDをクリアした上でnilを返します。
こうすることでTerraformとしてはリソースは存在しないものとして認識し、再度作成が必要といった判断を行ってくれます。

ここでは、IDによる検索で予期せぬエラーが発生した場合と実リソースが見つからなかった場合を戻り値などから区別できることが必要です。
実リソースが存在しない場合にもエラーを返してしまった場合、terraform planterraform destroyの実行ができないという状況になる可能性もあります。

というのも、Terraformはplanapplydestroyの実行の前にStateを最新の状態にするためにRefreshという処理を行います。
Refreshは単に各リソースのReadを呼び出すのですが、Readがエラーを返すとそこで処理が中断されてしまうからです。 実リソースが存在しない場合はTerraformではもはや手の打ちようがないですので、d.SetId("")を呼び出すことでStateからリソースの値をクリアします。

ごく稀にこの辺の処理を正しく行えていないリソースがあり、applyもdestroyもできないという状況になることがあります。
そのような場合の応急処置としてterraform.tfstateファイルから該当リソース部分を手作業で削除という泥臭い解決方法もあります。

IDを元に最新の実リソースを参照できるようになった後はd.Set()を呼び出してリソースの値を設定していきます。

Updateの実装

続いてUpdateの実装を行います。 Updateでは以下の処理を行う必要があります。

  • *schema.ResourceDataId()を呼び出してリソースのIDを取得
  • 各プラットフォームのAPIなどを呼び出しIDに対応する実リソースの値を更新
  • *schema.ResourceDataSet()を呼び出してリソースの値を設定

Createと同じくd.Get()などでtfファイルからの入力値を取得して実リソースの値を更新していきます。
*schema.ResourceDataには以下のようないくつかのヘルパー関数がありますので、変更のあった項目だけ更新するといったことも可能です。

*schema.ResourceDataの変更検知用関数
  • d.HasChange("<項目名>"): 対象項目が変更されているかを判定(戻り値: bool)
  • d.GetChange("<項目名>"): 対象項目の変更前/変更後の値を取得(戻り値: interface{}, interface{})

basicリソースでは以下のように実装します。

func resourceMinimumBasicUpdate(d *schema.ResourceData, meta interface{}) error {
    id := d.Id()

    // IDを元に実リソースを検索
    data, err := client.Read(id)
    if err != nil {
        // 実リソースが見つからなかったら
        if state.IsStateNotFoundError(err) {
            d.SetId("")
            return nil
        }
        return err
    }

    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    // set updated values
    if d.HasChange("name") {
        value.Name = d.Get("name").(string)
    }

    // save values
    if err := client.Update(id, value); err != nil {
        return err
    }

    return resourceMinimumBasicRead(d, meta)
}

ここでもReadと同じくIDを用いて実リソースを検索し、見つからなかったらd.SetId("")を呼び出した上でnilを返しています。

あとはd.HasChange()で変更があった項目をセットした上でプラットフォームのAPIを呼び出し実リソースを更新します。

Createと同じくRead用のfuncを直接呼び出すことでd.Set()を行っています。

Deleteの実装

続いてDeleteの実装を行います。 Deleteでは以下の処理を行う必要があります。

  • *schema.ResourceDataId()を呼び出してリソースのIDを取得
  • 各プラットフォームのAPIなどを呼び出しIDに対応する実リソースの値を削除

CreateUpdateと比較するとシンプルな実装になることが多いですね。

basicリソースでは以下のように実装します。

func resourceMinimumBasicDelete(d *schema.ResourceData, _ interface{}) error {
    id := d.Id()
    if err := client.Delete(id); err != nil {
        if !state.IsStateNotFoundError(err) {
            return err
        }
    }
    d.SetId("")
    return nil
}

IDを用いてプラットフォームのAPIで実リソースの削除を行うだけですね。
なお、Deleteではエラーを返さなければd.SetId("")があとで呼ばれるようになっていますのでここでのd.SetId("")は省略可能だったりします。
(公式ガイドにもその旨が書いてあります。明確にするためにあえて書いているとのことです。)

参考: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L197-L203

以上でリソースの基本的な実装ができました。

余談: Existsは実装すべき?

schema.Resourceのソースを読むと、Existsというfuncがあり、実装を推奨(recommended)すると言うコメントが書かれています。

参考: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L59-L86

   // The functions below are the CRUD operations for this resource.
    //
    // [...中略...]
    //
    // Exists is a function that is called to check if a resource still
    // exists. If this returns false, then this will affect the diff
    // accordingly. If this function isn't set, it will not be called. It
    // is highly recommended to set it. The *ResourceData passed to Exists
    // should _not_ be modified.
    Create CreateFunc
    Read   ReadFunc
    Update UpdateFunc
    Delete DeleteFunc
    Exists ExistsFunc

これはReadで行っていた、IDによる実リソースの存在確認を実装するためのもので、セットされているときだけ呼ばれるようになっています。
推奨という割には実装していないリソースもチラホラと散見されます。(例: AWSプロバイダーのEC2リソース aws_instanceなど)

実際にはReadが正しく実装されていればExistsは実装しなくても構いません。
Readとの違いは呼ばれるタイミングです。

ExistsReadの間にはスキーママイグレーション処理(次回以降に扱う予定)が行われています。
参考: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/resource.go#L319-L352

このあたりの処理が重くなるようであればExistsを実装した方が良いですが、最初はReadの実装のみで十分でしょう。

(2) Provider()に(1)で実装したfuncを追記

リソースの実装ができましたので、Provider()にリソースを登録しておきます。

minimum/provider.goを以下のようにします。

package minimum

import (
    "github.com/hashicorp/terraform/helper/schema"
    "github.com/hashicorp/terraform/terraform"
)

// Provider returns a terraform.ResourceProvider.
func Provider() terraform.ResourceProvider {
    return &schema.Provider{
        Schema: map[string]*schema.Schema{},
        ResourcesMap: map[string]*schema.Resource{
            "minimum_basic": resourceMinimumBasic(), // この行を追記
        },
    }
}

これでbasicリソースの実装ができました。

動作確認

実際にビルドして試してみましょう。 tfファイルはbasicブランチの直下にexample.tfがというファイルを置いていますのでそれを利用します。

# ビルド
$ go build -o terraform-provider-minimum main.go

# init & apply
$ terraform init
$ terraform apply

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + minimum_basic.example
      id:   <computed>
      name: "foobar"


Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: < yesを入力

minimum_basic.example: Creating...
  name: "" => "foobar"
minimum_basic.example: Creation complete after 0s (ID: 2012225891)

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

applyが成功するとカレントディレクトリにbasic-nnnnnnnn.json(nはランダムな数字)というファイルが作成されているはずです。
tfファイルやJSONファイルを書き換えてapplyを実行したりdestroyを実行したりと色々試してみてください。

basicリソースのソース全体

まとめとしてbasicリソースのソース全体を載せておきます。

package minimum

import (
    "encoding/json"

    "github.com/hashicorp/terraform/helper/schema"
    "github.com/yamamoto-febc/terraform-provider-minimum/state"
)

var client = state.NewDriver("basic")

func resourceMinimumBasic() *schema.Resource {
    return &schema.Resource{
        Create: resourceMinimumBasicCreate,
        Read:   resourceMinimumBasicRead,
        Update: resourceMinimumBasicUpdate,
        Delete: resourceMinimumBasicDelete,
        Schema: map[string]*schema.Schema{
            "name": {
                Type:     schema.TypeString,
                Required: true,
            },
        },
    }
}

func resourceMinimumBasicCreate(d *schema.ResourceData, meta interface{}) error {
    name := d.Get("name").(string)
    value := &state.BasicValue{
        Name: name,
    }

    id, err := client.Create(value)
    if err != nil {
        return err
    }

    d.SetId(id)
    return resourceMinimumBasicRead(d, meta)
}

func resourceMinimumBasicRead(d *schema.ResourceData, _ interface{}) error {
    id := d.Id()
    data, err := client.Read(id)
    if err != nil {
        if state.IsStateNotFoundError(err) {
            d.SetId("")
            return nil
        }
        return err
    }

    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    d.Set("name", value.Name) // nolint

    return nil
}

func resourceMinimumBasicUpdate(d *schema.ResourceData, meta interface{}) error {
    id := d.Id()

    // read current state
    data, err := client.Read(id)
    if err != nil {
        if state.IsStateNotFoundError(err) {
            d.SetId("")
            return nil
        }
        return err
    }

    value := &state.BasicValue{}
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    // set updated values
    if d.HasChange("name") {
        value.Name = d.Get("name").(string)
    }

    // save values
    if err := client.Update(id, value); err != nil {
        return err
    }

    return resourceMinimumBasicRead(d, meta)
}

func resourceMinimumBasicDelete(d *schema.ResourceData, _ interface{}) error {
    id := d.Id()
    if err := client.Delete(id); err != nil {
        if !state.IsStateNotFoundError(err) {
            return err
        }
    }
    d.SetId("")
    return nil
}

第2回 まとめ

第2回では単純なリソースの実装を行ってみました。 操作したいリソースにAPIさえ提供されていれば意外と簡単に実装できますね。

次回はスキーマ定義(schema.Schema)についてもう少し深掘りしてみます。

以上です。

Terraform: Up and Running: Writing Infrastructure as Code

Terraform: Up and Running: Writing Infrastructure as Code

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス

Infrastructure as Code ―クラウドにおけるサーバ管理の原則とプラクティス