Terraform Provider実装 入門(3): スキーマ定義 前編

f:id:febc_yamamoto:20180914185855p:plain

目次(未確定)

前回はCustom Providerにリソースを追加し基本となるCRUD操作を実装してみました。
今回はリソースで扱う入出力項目であるスキーマの定義について詳しくみていきます。

なお、今回も手を動かしながら確認できるようにソースを以下に置いています。

https://github.com/yamamoto-febc/terraform-provider-minimum/tree/types

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

#チェックアウト
$ cd $GOPATH/github.com/yamamoto-febc/terraform-provider-minimum
$ git fetch -a
$ git checkout types

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

schema.Schemaでのスキーマ定義

前回作成したbasicリソースではスキーマを以下のように定義していました。

import (
    // [...中略...]
    "github.com/hashicorp/terraform/helper/schema"
)

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

文字列型で必須なnameという項目一つだけの単純なものです。
項目を増やすにはSchemaに項目を追加していくだけでOKです。(もちろんCRUD操作の中でd.Set()を適度に呼び出すなど適切に実装する必要があります)
各項目はSchemaに設定するmapの要素であるschema.Schemaのフィールドを適切に設定することでデータ型や制約、デフォルト値などを柔軟に制御可能です。

まずはschema.Schema型について詳しくみていきます。

schema.Schema

schema.Schema型はリソースが扱う入出力項目それぞれの振る舞いを定義するための構造体です。

以下のようなフィールドが定義されています。 ("フィールド名(型) : 説明" で表記)

  • データ型
    • Type(schema.ValueType) : データ型
  • 入出力動作の指定(1つ以上の指定必須)
    • Optional(bool) : trueの場合、省略可能になる
    • Required(bool) : trueの場合、必須となる
    • Computed(bool) : trueの場合、作成時に計算(算出)される(値が未指定の場合のみ)
  • デフォルト値関連
    • Default(interface{}) : デフォルト値(値で指定)
    • DefaultFunc(schema.SchemaDefaultFunc) : デフォルト値(funcで指定)
  • 値変更/保存時の挙動関連
    • DiffSuppressFunc(schema.SchemaDiffSuppressFunc) : 差分検出で使用するfunc
    • ForceNew(bool) : trueの場合は変更時にUpdateではなくDestroy -> Createが行われる
    • StateFunc(schema.SchemaStateFunc) : State保存の際に呼ばれるフック
  • センシティブなデータの扱い
    • Sensitive(bool) : trueの場合、この項目をログ/標準出力に出力する際にマスクされる
  • TypeがTypeList or TypeSetの場合に使用するフィールド
    • Elem(interface{}) : ListまたはSetの各要素のデータ型
    • MaxItems(int) : ListまたはSetに格納できる最大数(境界含む/1以上の場合のみ有効)
    • MinItems(int) : ListまたはSetに格納できる最小数(境界含む/1以上の場合のみ有効)
    • PromoteSingle(bool) : trueの場合、単一の値として指定された場合に自動的にリストに変換する(プリミティブ型のみで有効)
  • TypeがTypeSetの場合に使用するフィールド
    • Set(schema.SchemaSetFunc) : 項目のハッシュ値算出で使用するfunc
  • バリデーション関連
    • ConflictsWith([]string) : 同時に指定できない項目の名称を指定
    • ValidateFunc(schena.SchemaValidateFunc) : 入力値のバリデーションで使用するfunc(プリミティブ型のみで有効)
  • 警告/エラーメッセージ関連
    • Deprecated(string) : 設定されている場合、この項目を利用すると警告メッセージを出す
    • Removed(string) : 設定されている場合、この項目を利用するとエラーメッセージを出す(validateplanapplyを異常終了させる)
  • v0.11時点では実装されていないフィールド
    • Description(string)
    • InputDefault(string)
    • ComputedWhen([]string)

必須項目

これらのうち、最低限指定しないといけないのは以下2つです。

  • Type
  • Optional/Required/Computedを1つ以上

まずはTypeからみていきます。

データ型を決めるType

Typeはデータ型を表します。以下の値が指定可能です。

  • TypeBool - bool
  • TypeInt - int
  • TypeFloat - float64
  • TypeString - string
  • TypeList - []interface{}
  • TypeMap - map[string]interface{}
  • TypeSet - *schema.Set

右側はResourceData.Get()を呼んだ時の戻り値の実際の型を表しています。
ResourceData.Get()の戻り値はinterface{}ですので、項目のデータ型に応じて適切にキャストする必要があります。

それぞれの特徴を順に見ていきます。

プリミティブ型(Bool/Int/Float/String)

それぞれのプリミティブ型を示します。特に説明不要ですね。
tfファイル上は値の書き方にバリエーションがある点には留意しておいてください。
(このへんはTerraformの実装というよりHCLの実装によります)

Terraform v0.12ではHCLの後継であるHCL2への切り替えが進められています。

以下にtfファイルの書き方例を記載しておきます。

TypeBool
resource minimum_bool "bool" {
  value = true 

  // 文字列で指定してもOK
  #value = "true"   #OK 
  #value = "foobar" #これはNG

  // 数値もOK(true=1,false=0)
  #value = 0  # OK
  #value = 2  # これはNG  
  #value = -1 # これはNG

  // 数値を文字列で指定してもOK
  #value = "0"  # OK
  #value = "2"  # これはNG
  #value = "-1" # これはNG
}
TypeInt
resource minimum_int "int" {
  value = 1

  // 8進数/16進数/指数もOK
  #value = 0777   # 8進数 
  #value = 0xFFFF # 16進数
  #value = 1e10   # 指数表記
 
  // 文字列で指定してもOK
  #value = "1" 
  
  // 範囲(golangのintの範囲)
  #value = -9223372036854775808
  #value = 9223372036854775807
  
  #value = 1.1 # これはNG
}

TypeString

resource minimum_string "string" {
  value = "foobar"
  
  // 数値で指定してもOK(文字列に変換される)
  #value = 0777   # 8進数 
  #value = 0xFFFF # 16進数
  #value = 1e10   # 指数表記
}

List型(TypeList)

次にTypeListを見ていきます。TypeListはプリミティブ型、または複合型のリストを表します。
スキーマ定義は以下のようになります。

func resourceMinimumList() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeList,
                Optional: true,
                Elem:     &schema.Schema{Type: schema.TypeString},
                // MaxItems: 1,
                // MinItems: 1,
            },
        },
    }
}

TypeListにする場合、Elemは必須です。Elemでリストの各要素のデータ型を指定する必要があります。
リスト内の要素がプリミティブ型の場合は*schema.Schemaを、複合型の場合は*schema.Resourceを指定します。 多段ネストも可能です。

なお、後述するTypeMapと違いElemschema.ValueTypeを直接指定することはできません。

正しくないElem指定の例
// !正しくないElem指定の例!
func resourceMinimumList() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeList,
                Optional: true,
                Elem:     schema.TypeString, // TypeListではValueTypeを直接指定できない
            },
        },
    }
}

次にリスト内に複合型を利用する場合のスキーマ定義です。

リスト内に複合型を利用する例
func resourceMinimumNestedList() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeList,
                Optional: true,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "name": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "value": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "description": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                    },
                },
            },
        },
    }
}

また、リストの要素数MinItems/MaxItemsで制限可能です。
いずれもデフォルトは0で、1以上の数値を指定すると有効になります。
(これらは後述するTypeSetでも利用可能です)

List型を含むリソースを利用するtfファイルは以下のようになります。

# プリミティブ型のリスト
resource minimum_list "list" {
  value = ["list0", "list1", "list2"]
}

# 複合型のリスト
resource minimum_nested_list "nested-list" {
  value = [
    {
      name        = "name1"
      value       = "value1"
      description = "description1"
    },
    {
      name        = "name2"
      value       = "value2"
      description = "description2"

      // 定義していない項目はちゃんとエラーにしてくれる
      # foo = "bar"
    },
  ]
}

List型の項目に対しResourceData.Get()すると[]interface{}が返ってきます。
各要素がプリミティブ型の場合は単にキャスト(TypeStringならstringへ)すればOKです。
複合型の場合はmap[string]interface{}になります。

上記のminimum_nested_listリソースの場合は以下のようにします。

func resourceMinimumNestedListRead(d *schema.ResourceData, meta interface{}) error {
    // ...
    values := d.Get("value").([]interface{}) // まず[]interface{}にキャスト
    for _ , v := range values {
      element := v.(map[string]interface{}) // 各要素はmap[string]interface{}
     
      name := element["name"].(string)
      value := element["value"].(string)
      desc := element["description"].(string)
     
      // ...
    }    
}

Map型(TypeMap)

続いてTypeMapを見ていきます。TypeMapはその名の通りキーと値をペアで持ちます。キーは文字列である必要があります。
スキーマ定義は以下のようになります。

func resourceMinimumMap() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeMap,
                Optional: true,
            },
        },
    }
}

リソースの実装時、Mapの項目に対しResourceData.Get()するとmap[string]interface{}が返ってきます。

また、Map型でも要素の型をElemで指定できますが、少々クセがある点に注意が必要です。

Map型の注意点

現状はMap型の項目に対しElemを指定した際は以下のような挙動となっています。

  • Elemが未指定の場合は各要素をTypeStringとみなす
  • Elemschema.ValueType(TypeIntとかTypeStringとか)が指定された場合
    • プリミティブ型であればそのまま使う
    • 以外の場合はTypeStringとみなす
  • Elem*schema.Schemaが指定された場合
    • *schema.SchemaTypeがプリミティブ型であればそのまま使う
    • 以外の場合はTypeStringとみなす
  • Elem*schema.Resourceが指定された場合
    • TypeStringとみなす

要はMapの各要素はプリミティブ型でないとダメということです。

この挙動はリストのように複合型を利用したい場合に問題が発生します。
例えば以下のようにスキーマ定義します。

// !問題のあるスキーマ定義!
func resourceMinimumInvalidMap() *schema.Resource {
    return &schema.Resource{
        // ...
        
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeMap,
                Optional: true,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "value1": {
                            Type:     schema.TypeString,
                            Required: true,
                        },
                        "value2": {
                            Type:     schema.TypeInt,
                            Optional: true,
                        },
                        "value3": {
                            Type:     schema.TypeBool,
                            Optional: true,
                        },
                    },
                },
            },
        },
    }
}

Map型の各要素として以下のような複合型を指定しています。

  • value1: 文字列型/必須
  • value2: Int型
  • value3: Bool型

このリソースを利用するtfファイルは以下の通りです。

resource minimum_invalid_map "invalid" {
  value = {
    value1 = "value1"
    value2 = 2
    value3 = true
  }
}

terraform validateterraform applyも問題なく行えるはずです。
しかし、以下のようにした場合はどうでしょうか?

resource minimum_invalid_map "invalid" {
  value = {
    # value1 = "value1" # Required=trueの項目をコメントアウト
    value2 = "foo"      # Int型の項目に文字列を指定
    value3 = "bar"      # Bool型の項目に文字列を指定
    value4 = "not exists" # 定義していない項目を指定
  }
}

なんとterraform validateterraform applyも問題なく行えてしまいました。
これはElemにプリミティブ型以外を指定してしまったために、各要素がTypeStringとみなされてしまうからです。

このようにMap型でElemを使う場合には直感的でない挙動となりますので、 もしtfファイル上で複合型を利用したい場合は後述のTypeSetか、MaxItems=1にしたTypeListを使う方法を検討してください。

なお、この辺のTypeMapの挙動については対応が進められてはいるのですが現時点では止まっちゃってるっぽいです。

github.com

Set型(TypeSet)

最後にTypeSetをみていきます。TypeSetは値のハッシュ機能付きのリストです。 スキーマ定義は以下のようになります。

func resourceMinimumSet() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "value": {
                Type:     schema.TypeSet,
                Optional: true,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "name": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "value": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                        "description": {
                            Type:     schema.TypeString,
                            Optional: true,
                        },
                    },
                },
                Set: func(v interface{}) int {
                    var buf bytes.Buffer
                    m := v.(map[string]interface{})
                 
                    // nameとvalueの値からハッシュ値を生成
                    keys := []string{"name", "value"}
                    for _, key := range keys {
                        if v, ok := m[key]; ok {
                            buf.WriteString(fmt.Sprintf("%s-", v.(string)))
                        }
                    }
                    return hashcode.String(buf.String())
                },
            },
        },
    }
}

TypeListの時と同じくElemが必須となっています。
違いはSetの部分ですね。
これは値からハッシュ値を算出する部分となっています。 (なおSetを利用せず、ResourceData.Set()するときにschema.NewSet()を利用する方法もあります。)

ポイントはハッシュ値が同一なTypeSetの要素はまとめられるという性質を持っている点です。

tfファイルでの例を見てみましょう。
この例のminimum_setリソースでは、各要素のnamevalueを元にハッシュ値を算出しています。

まずはハッシュ値が異なる要素の場合です。

resource minimum_set "set" {
  value = [
    {
      name        = "name1"
      value       = "value1"
      description = "description1"
    },
    {
      name        = "name2"
      value       = "value2"
      description = "description2"
    },
  ]
}

terraform applyすると以下のようになります。

$ 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_set.set
      id:                           <computed>
      value.#:                      "2"
      value.1049404950.description: "description1"
      value.1049404950.name:        "name1"
      value.1049404950.value:       "value1"
      value.741132560.description:  "description2"
      value.741132560.name:         "name2"
      value.741132560.value:        "value2"


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

minimum_set.setvalueの要素として2つの要素が作成される様子が確認できますね。
次にtfファイルを修正しnamevalueを同一の値にしてハッシュ値が同じになるようにしてみます。

$ 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_set.set
      id:                           <computed>
      value.#:                      "1"
      value.1049404950.description: "description2"
      value.1049404950.name:        "name1"
      value.1049404950.value:       "value1"


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

今度はminimum_set.setvalueの要素として1つの要素が作成される様子が確認できますね。
前述の通りハッシュ値が同じ要素はまとめられるという性質によりこうなっています。

一見するとめんどくさいだけなようにも思えますが、もちろん役に立つ場面が存在します。
例えばDNSのAレコードを扱う場合を考えてみます。

Aレコードは一つのホスト名に対し複数のIPアドレスを指定できますよね。
この場合単純にホスト名をキーにしたマップを利用するというわけにはいかないでしょう。

正しくない例: レコードをマップで表す場合
# !正しくない例! レコードをマップで表す場合
resource dummy_dns_zone "zone" {
    zone = "example.com"
 
    records = {
        "www" = "192.2.0.1", # mapなので重複したキーは指定できない 
        "www" = "192.2.0.2", #
    }
}

単純にリストにすればキーに当たるホスト名は重複させられますが、今度はホスト名/IPアドレスの組み合わせが重複する可能性があります。

正しくない(かもしれない)例: レコードをリストで表す場合
# !正しくない(かもしれない)例! レコードをリストで表す場合
resource dummy_dns_zone "zone" {
    zone = "example.com"

    # リストの場合は値の重複が起こり得る        
    records = [
      {
          name = "www",
          ip   = "192.2.0.1",
      },
      {
          name = "www",
          ip   = "192.2.0.1",
      },
    ]
}

ResourceData.Get()を行なった際に手動でバリデーションを行えば良いのですが、TypeSetであればこの辺りが楽に行えるようになっています。

# レコードをセット(TypeSet)で表す場合
resource dummy_dns_zone "zone" {
    zone = "example.com"

    # TypeSetの場合はハッシュ生成に使う値が同じならまとめられる
    records = [
      {
          name = "www",
          ip   = "192.2.0.1",
      },
      {
          name = "www",
          ip   = "192.2.0.1",
      },
    ]
}

注意点として、TypeSetを用いることで、tfファイルには2レコード書いたつもりなのに(書き間違えにより意図せず)1レコードしか作成されない!といったことが発生し得ます。
TypeListを利用してバリデーションを実装することで重複チェックを行う方法もありますので、適度に使い分けましょう。

また、TypeSetを利用した場合、リソースのテストが書きにくい(リソースIDがハッシュ値になるため)という問題もあります。
この辺りは次回以降の記事でテスティングフレームワークを扱う際に改めて触れます。


Typeは以上です。これらを組み合わせてリソースに対する入出力項目を定義していくことになります。
各データ型の特徴をしっかり押さえておきましょう。

第3回 まとめ

第3回ではスキーマ定義に利用するschena.Schema型のフィールドのうち、データ型を表すTypeについて扱いました。
次回はschema.Schemaの他のフィールドについて見ていきます。

以上です。

Terraform: Up and Running: Writing Infrastructure as Code

Terraform: Up and Running: Writing Infrastructure as Code

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

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