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

f:id:febc_yamamoto:20180914185855p:plain

目次(未確定)

前回スキーマ定義として必須項目であるType(データ型)について扱いました。
今回はschema.Schemaの他のフィールドについて見ていきます。

前回、*schema.Schemaに最低限指定しないといけないフィールドとして以下2つを挙げました。

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

Typeは前回扱いましたので、今回はもう一方のOptional/Required/Computedについて扱います。

入出力動作(Required/Optional/Computed)

項目の入力/出力動作を決めるフィールドです。 これらは1つ以上指定(trueに設定)する必要があります。

それぞれの意味は以下の通りです。

  • Optional : bool : trueの場合、省略可能になる
  • Required : bool : trueの場合、必須となる
  • Computed : bool : trueの場合、値は算出される(値が未指定の場合のみ)

これらを組み合わせて指定するのですが中には組み合わせ出来ないものもありますので、取りうるパターンは以下4つだけです。

入出力動作で指定可能な組み合わせ

  • Required: true : 入力必須な項目
  • Optional: true : 省略可能な項目
  • Optional:true かつ Computed: true : 省略可能な項目(省略した場合はリソースが算出する)
  • Computed: true : 出力専用の項目(値はリソースが算出する)

このうち、Computedは出力専用、それ以外は入力項目となります。

特殊な項目: 入力専用

また、少々特殊なものとして、Required/Optionalを使いつつResourceData.Set()を呼ばない事で入力専用の項目を作ることも可能です。

詳しくはサンプル実装を動かしながら見ていきます。

サンプル実装inoutリソースでの動作確認

これらの項目の動作確認のためにminimum_providerinoutというリソースを実装しています。 今回も手を動かしながら確認できるようにソースを以下に置いています。

github.com

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

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

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

inoutリソースでのスキーマ定義

inoutリソースはRequired/Optional/Computedの組み合わせを確認するために以下のようにスキーマ定義しています。

func resourceMinimumInOut() *schema.Resource {
    return &schema.Resource{
        // ...
     
        Schema: map[string]*schema.Schema{
            "required": {
                Type:     schema.TypeString,
                Required: true,
            },
            "optional": {
                Type:     schema.TypeString,
                Optional: true,
            },
            "optional_computed": {
                Type:     schema.TypeString,
                Optional: true,
                Computed: true,
            },
            "computed": {
                Type:     schema.TypeString,
                Computed: true,
            },
            "input_only": {
                Type:     schema.TypeString,
                Optional: true,
            },
        },
    }
}

先ほどの指定可能な組み合わせ4つそれぞれに対応した項目があります。
また、入力専用の項目としてinput_onlyという項目も定義しています。

このうち、computedについては現在の日時(RFC3339形式)が値として設定されます。
また、optional_computedはtfファイルで値が指定されなかった場合に現在の日時を値として設定するようにしています。

実際に動きを見てみましょう。

Requiredの動作確認

まずは以下のようにinoutリソースに対してどの項目も未指定の状態のtfファイルを作成してapplyしてみます。

resource minimum_inout "inout" { }

applyするとRequired: trueの項目が未指定である旨を表示してエラーとなってくれます。

$ terraform apply

Error: minimum_inout.inout: "required": required field is not set
Requiredの注意点

注意点として、Requiredは値がtfファイル上で指定されていればOKと判定します。

例として、以下のようにスキーマ定義していた場合、

       Schema: map[string]*schema.Schema{
            "string": {
                Type: schema.TypeString,
                Required: true,
            },
            "int": {
                Type: schema.TypeInt,
                Required: true,
            },
            "bool": {
                Type: schema.TypeBool,
                Required: true,
            },
            "list": {
                Type: schema.TypeList,
                Elem: &schema.Schema{Type: schema.TypeString},
                Required: true,
            },
        },

次のようなtfファイルであればapply出来てしまいます。

resource dummy_required "example" {
  string = ""
  bool   = false
  int    = 0
  list   = []
}

Required: trueでも有効な値が指定されたとは限らない点にご注意ください。
この辺りは必要であれば後述するValidateFuncフィールドを用いてバリデーションを行う事で対応可能です。

Computedの動作確認

次にComputedな項目について確認してみます。
tfファイルで必須項目であるrequired項目に値を設定してみましょう。

resource minimum_inout "inout" {
  required = "required"
}

applyするとcomputedoptional_computedに現在日時が値として設定されているはずです。

$ terraform apply

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

# Stateを確認
$ terraform show
minimum_inout.inout:
  id = 596549384
  computed = 2018-09-17T20:49:49+09:00           #現在日時が設定されている
  optional = 
  optional_computed = 2018-09-17T20:49:49+09:00  #現在日時が設定されている
  required = required

なお、Computed: trueのみの項目にtfファイル上で値を設定しようとすると当然ながらエラーになります。

# !正しくないtfファイルの例!
resource minimum_inout "inout" {
  required          = "required"
  
  // Computed: trueのみの項目には値の指定は出来ない
  computed          = "computed"
}
$ terraform apply

Error: minimum_inout.inout: "computed": this field cannot be set

Optional+Computedの動作確認

次にtfファイル上でoptional_computedに値を設定してみます。

resource minimum_inout "inout" {
  required          = "required"
  optional_computed = "optional_computed"
}

applyすると今度はoptional_computedに現在日時ではなくtfファイルで指定した値が設定されているはずです。

$ terraform apply

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

# Stateを確認
$ terraform show
minimum_inout.inout:
  id = 596549384
  computed = 2018-09-17T20:54:06+09:00
  optional = 
  optional_computed = optional_computed  #tfファイルで設定した値が反映された
  required = required

Optional+Computedの使い所

なぜこんな項目が指定するのか疑問に思う方もいらっしゃるかもしれません。
これは、指定しなければプラットフォーム側が自動で割り当ててくれる、といった項目のためのものです。

例えばAWSのEC2におけるAZ(アベイラビリティゾーン)などが該当します。

参考: AWS: EC2リソースでのスキーマ定義(AZ部分抜粋)

ソース: https://github.com/terraform-providers/terraform-provider-aws/blob/v1.36.0/aws/resource_aws_instance.go#L61-L66

func resourceAwsInstance() *schema.Resource {
    return &schema.Resource{
        // ...
        Schema: map[string]*schema.Schema{
            // ...
            "availability_zone": {
                Type:     schema.TypeString,
                Optional: true,
                Computed: true,
                ForceNew: true,
            },
            // ...
        },
    }
}    

入力専用の項目

スキーマ定義をしてResourceData.Set()を呼ばない項目は入力専用の項目となります。
この場合、tfファイルからの入力でStateには保存されますが、ResourceData.Set()が呼ばれないので実リソースの値が反映される事がないということです。

この場合でもtfファイルを変更すればTerraformが変更を検知してくれますので実リソースへの反映は可能です。

使いどころとしては実リソースから値が取得できないような項目、例えばパスワードといった項目があります。
入力専用の項目はソースコードtfschemaといったツールでスキーマ定義を確認しただけではそうと判断できないので注意が必要です。

また、入力専用の項目は実リソースの値がStateに反映されないため、実リソースを(コントロールパネルなどを利用して)直接変更しても検知できない問題があります。
このため、実リソースの状態とtfファイルやterraform.tfstateの内容が食い違う可能性があります。
これを避けるにはTerraformで管理している実リソースについてはTerraform以外の手段での変更をなるべく避けるなど、運用面での工夫も必要となります。


これでschema.Schemaの最低限の設定項目を押さえました。
残りのフィールドについても見ていきましょう。

なお、ここからはサンプルを別ブランチに格納しています。

github.com

schemaブランチをチェックアウトしてビルドしておいてください。

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

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

デフォルト値(Default/DefaultFunc)

その名の通り未指定の場合のデフォルト値を指定します。
Defaultには値を直接指定し、DefaultFuncにはデフォルト値を返すfuncを指定します。funcのシグニチャfunc() (interface{}, error)です。

なぜか両方指定することも可能ですが、両方指定した場合はDefaultが優先されます。
該当部分のソース: https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/schema.go#L257-L273

それぞれを詳しく見ていきましょう。

Default/DefaultFuncで共通の部分

まず両者で共通しているのは以下の点です。

  • Computed: trueの場合は指定できない
  • TypeListまたはTypeSetの場合は指定できない

基本的にOptional: trueなプリミティブ型の項目に指定しますが、DefaultFuncについては例外的にRequired: trueでも指定できます。

DefaultFuncのみRequired: trueでも設定できる

DefaultFuncは値の決定時に指定のfuncを実行する事で動的に値を決定するものです。
これを利用して、「APIキーなどのtfファイルに直接記載して欲しく無いけど必要な情報」を環境変数から取得する、といった使い方ができます。

サンプルで動きを確認してみましょう。
今回はminimumプロバイダーにschemaリソースというschema.Schemaの様々なフィールドを試すためのリソースを用意しました。

Required: trueかつDefaultFuncを設定しているdefault_func_requiredという項目を以下のように定義しています。

func resourceMinimumSchema() *schema.Resource {
    return &schema.Resource{
        // ...
        Schema: map[string]*schema.Schema{
            // ...
         
            "default_func_required": {
                Type:     schema.TypeString,
                Required: true,
                DefaultFunc: schema.EnvDefaultFunc("MINIMUM_DEFAULT", nil),
            },
         
            // ...
        },
    }
}

DefaultFuncにschemaパッケージで提供されているヘルパー関数EnvDefaultFuncというfuncを指定しています。
これは与えられた環境変数名(第1引数)の値を参照し、設定されていればそれを、なければ第2引数(ここではnil)の値を返してくれるものです。

default_func_requiredRequired: trueなので指定しないとterraform apply時にエラーになります。

tfファイルまたは環境変数MINIMUM_DEFAULTで値を指定するとterraform apply出来るようになります。

DefaultFuncで利用できるヘルパー関数

先ほども利用していましたが、schemaパッケージではDefaultFuncで利用できるヘルパー関数を提供しています。

  • EnvDefaultFunc: 前述の通り
  • MultiEnvDefaultFunc: シグニチャfunc ([]string, interface{}) SchemaDefaultFunc
    第1引数が配列になっており、環境変数名を複数指定可能。第2引数はEnvDefaultFuncと同様

Default/DefaultFuncの定義を変更する際の注意点

Defaultに指定する値、またはDefaultFuncで返す値を変更するのはbreaking-changeになります。

例えばリソースが以下のように定義されているとします。

       Schema: map[string]*schema.Schema{
            // ...
         
            "value": {
                Type:     schema.TypeString,
                Optional: true,
                Default: "before",
            },
         
            // ...
        },

デフォルト値が設定されているため、tfファイルにvalue項目は指定しなくてもよいですよね。

resource dummy_resource {
  name = "foobar"
  # value = "" # valueは指定せずデフォルトのまま
}

この状態でプロバイダーのバージョンアップを行いデフォルト値を変更したとします。

       Schema: map[string]*schema.Schema{
            // ...
         
            "value": {
                Type:     schema.TypeString,
                Optional: true,
                Default: "after",
            },
         
            // ...
        },

すると、これまでこのリソースを利用していた場合はプロバイダーのバージョンアップをしただけで影響を受けてしまいます。

特に後述するForceNew: true(変更時に削除/作成が行われる)な項目の場合だと影響が大きくなる可能性があります。
このため、出来るだけ変更しないで済むような値を設定する事が望ましいです。

とはいえどうしても変更を避けられない場合もあります。
そのような場合はRefresh処理(第2回を参照)で呼ばれるReadの中で適切にResourceData.Set()する、 または次回以降に扱うスキーママイグレーション処理を行なって適切な値を設定するといった対応が必要となります。

値変更/保存時の挙動(DiffSuppressFunc/ForceNew/StateFunc)

次に値の変更や保存時の挙動を指定するためのフィールドです。

差分の検出を抑制するDiffSuppressFunc

Terraformはplanapply実行時に実リソースとtfファイルでの指定の差分を検出してくれます。
通常はデフォルトの差分検出を利用すれば良いですが、中には差分を無視したいケースもあります。

例えばtfファイルと実リソースで表記方法が違うだけで意味合いが同じといった場合などです。 具体的な用途としては

  • 大文字/小文字の違いを無視する
  • SSH公開鍵で末尾の改行有無を無視する

といったものがあります。

型はschema.SchemaDiffSuppressFuncとなっており、シグニチャfunc(key, old, new string, d *ResourceData) boolです。

第1〜3引数は項目のキー、変更前の値、変更後の値となっています。 第4引数はCRUD操作時に渡されるものと同じ*ResourceDataです。

同じとみなす場合はtrueを、差分ありとみなす場合はfalseを返すようにします。

例として、大文字/小文字の違いを無視したい場合は以下のようにします。

   Schema: map[string]*schema.Schema{
        // ...
     
        "diff_suppress_func": {
            Type:     schema.TypeString,
            Optional: true,
            DiffSuppressFunc: func(key, old, new string, d *ResourceData) bool {
                return strings.ToLower(old) == strings.ToLower(new) // 小文字に変換して比較  
            },
        },
     
        // ...
    },

なお、DiffSuppressFuncスキーマ内の項目単位で制御しますが、リソース全体で制御するためのものとしてschema.ResourceCustomizeDiffというフィールドもあります。
こちらについては次回以降の記事で扱います。

変更されたらリソースを削除-作成するForceNew

ForceNew: trueにすると変更後のapply実行時にリソースのUpdateではなく、DestroyしてCreateするようになります。
これは作成時のみ指定が可能で以後変更できないような項目などで利用します。

また、第2回で触れた、Updateを省略する時の条件にも利用されます。
Updateを省略するにはComputed: trueでない項目全てにForceNew: trueをつけておく必要があります。
Updateを省略 = 更新できない = 更新時は削除->作成が必要なリソース、ということですね。

乱用すると思わぬところでリソースの削除が行われる原因となりえますので適度で適切な利用を心がけましょう。

Stateへの格納の際に呼ばれるフックStateFunc

Stateに値を格納する時にフックを通じて値の加工を行う事ができます。
例えば長い文字列をStateに格納する代わりにハッシュ値を保存するといった使い方です。

以下のように利用します。

   Schema: map[string]*schema.Schema{
        // ...
     
        "state_func": {
            Type:     schema.TypeString,
            Optional: true,
            StateFunc: func(value interface{}) string {
                return hash(value.(string)) // 値のハッシュ値を返す
            },
        },
     
        // ...
    },
// ...

func hash(v string) string {
    return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
}

StateFuncResourceData.Set()を呼んだ際には利用されないことに注意が必要です。
ResourceData.Set()を呼ぶ際はStateFuncと同様の処理を自分で行う必要があります。

   d.Set("state_func", hash(value.StateFunc)) // StateFuncと同様の処理

結構面倒ですね。なのでStateFuncは基本的にアップロードするファイルのコンテンツといった入力専用(ResourceData.Set()をしない)項目に対して用いられる事が多いです。

本来パスワードなどの難読化といった用途でも利用できるはずなのですが、ざっとTerraform Providers配下を探してみても見当たりませんでした(よく探せばあるのかも?)。

パスワードなどのセンシティブなデータの扱いについては次のSensitiveも参照してください。

センシティブなデータをマスクするSensitive

Sensitive: trueにすると、ログや標準出力への出力時に値がマスクされるようになります。

マスクされている様子
$ terraform apply

  + minimum_schema.schema_example
      id:                    <computed>
      sensitive:             <sensitive>

注意点として、Sensitive: trueにしてもStateに保存される際にはマスクされません。
例えばバックエンドがローカルの場合に作成されるterraform.tfstateファイルには生の値がそのまま保存されます。

また、Outputとして利用する場合にも同様にマスクされません。
(Output側で改めてSensitive指定すればマスクされます。参考:https://www.terraform.io/docs/configuration/outputs.html)

一応ソースコードのコメントにはFuture versions of Terraform may encrypt these valuesとありますので、今後対応されるのかもしれません。
https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/schema.go#L195-L199

バリデーション(ValidateFunc/ConflictsWith)

項目のバリデーションで利用するフィールドです。

入力値のバリデーションを行うValidateFunc

入力値に対するバリデーションを行うためのfuncを指定可能です。
型はschema.SchemaValidateFuncで、シグニチャfunc(value interface{},key string) (warns []string,errs []error)です。

以下は入力された文字列の長さが1から5の間でなければエラーとする例です。

   Schema: map[string]*schema.Schema{
        // ...
     
        "validate_func": {
            Type:     schema.TypeMap,
            Optional: true,
         
            // ValidateFunc: validation.IntBetween(1, 5)と同じ
            ValidateFunc: func(value interface{}, k string) (ws []string, es []error) {
                v , ok := value.(string)
                if !ok {
                    es = append(es, fmt.Errorf("expected type of %s to be string", k))
                    return
                }
                if !(1 <= len(v) && len(v) <= 5) {
                    es = append(es, fmt.Errorf("expected length of %s to be in the range (%d - %d), got %s", k, 1, 5, v))
                }
                return
            },
        },
     
        // ...
    },

引数に項目の値とキーが渡されますのでそれらを利用してバリデーションを行います。 戻り値は警告とエラーを配列です。バリデーションの結果をそれぞれに格納してください。

組み込みのバリデーション関数

github.com/hashicorp/terraform/helper/validationパッケージにはValidateFuncで利用できる関数が用意されています。

github.com

なるべくこれらを利用し、不足する機能についてのみ自前実装するようにしましょう。
先ほどのvalidate_funcの例であればvalidation.IntBetween(1, 5)のように利用します。

ValidateFuncの注意点

現在はTypeListTypeSetは非対応です。
https://github.com/hashicorp/terraform/blob/v0.11.8/helper/schema/schema.go#L192

これらのバリデーションを行いたい場合はElemで複合型を指定してそちらにValidateFuncを書く(もちろんプリミティブ型のみ)か、
Create/Updateなどの中で自分でバリデーションを実装するかしないといけません。

コメントにはValidateFunc currently only works for primitive typesと書いてますが実はTypeMapはバリデーションできたりします。

複数の項目の同時指定を防ぐConflictsWith

ConflictsWithは同時に指定できない項目の名称を文字列の配列で指定します。

   Schema: map[string]*schema.Schema{
        // ...

        "conflicts_with1": {
            Type:          schema.TypeString,
            Optional:      true,
            ConflictsWith: []string{"conflicts_with2"},
        },
        "conflicts_with2": {
            Type:          schema.TypeString,
            Optional:      true,
            ConflictsWith: []string{"conflicts_with1"},
        },   
 
        // ...
    },

両方を指定すると以下のようなエラーメッセージを出してくれます。

$ terraform validate

Error: minimum_schema.schema_example: "conflicts_with1": conflicts with conflicts_with2

Error: minimum_schema.schema_example: "conflicts_with2": conflicts with conflicts_with1

警告/エラーメッセージ(Deprecated/Removed)

プロバイダーのバージョンアップなどでスキーマ定義が変更になり利用しなくなる/なった項目が発生した場合に利用します。
どちらも文字列を指定しておくと、その項目を利用している場合に警告/エラーを表示してくれます。
なお、Removedの場合はterraformコマンド自体を異常終了してくれます。

   Schema: map[string]*schema.Schema{
        // ...

        "deprecated": {
            Type:       schema.TypeString,
            Optional:   true,
            Deprecated: "deprecated",
        },
        "removed": {
            Type:       schema.TypeString,
            Optional:   true,
            Removed: "removed",
        },
 
        // ...
    },

これらを指定していると以下のような警告/エラーメッセージを出してくれます。

$ terraform validate

Warning: minimum_schema.schema_example: "deprecated": [DEPRECATED] deprecated

Error: minimum_schema.schema_example: "removed": [REMOVED] removed

なお、両者ともOptional: trueでデフォルト値なしにしておかないと常に警告/エラーが出ますのでご注意ください。

第4回 まとめ

第4回ではスキーマ定義に利用するschena.Schema型のフィールドについて扱いました。
次回はスキーマを持つリソースの操作を行うためのschema.ResourceDataについて見ていきます。

以上です。

Terraform: Up and Running: Writing Infrastructure as Code

Terraform: Up and Running: Writing Infrastructure as Code

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

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