📚

ゼロから始めるTerraform Custom Provider

2024/09/04に公開

はじめに

カタログツールの運用大変じゃないですか?

日々データカタログを運用されている皆様、お疲れ様です。
データカタログの運用大変じゃないですか?
私もOpenMetadata(OSSのカタログ製品)の試験導入が始まった段階で、今後の運用面をどうにかしないと・・・と漠然と思ってました。
特に日々の運用で発生しそうな次のユースケースはなるべく自動化/省力化したいと思っていました。

  • ユーザ管理(作成/更新/削除)
  • Database Servicesの管理(追加/更新)
  • Termの管理(追加/更新/削除)
  • TestCase周りの管理(作成/更新/削除)
  • Policy/Roleの管理
  • Classificationの管理

などなど・・・・

これらの作業を手でやるには限界があり、依頼がある度に開発の手を止められてしまい、チームとしての運用開発スピードに妨げにもなります。
さらに、手動はオペミスの温床にもなるので絶対に避けたい・・・

なので、自動化/省力化のために、OpenMetadata上のリソースは全てTerraformで管理する方向で検討を始めました。
幸いなことに、SnowflakeやAWSでもTerraformを採用しているので、導入障壁はかなり低いのが嬉しいポイント!!

ただし、OpenMetadataはTerraformのプロバイダー一覧に存在しない・・・???

プロバイダー一覧でOpenMetadata検索してもヒットしない。。。
私の探し方が悪いのか???でもネットで検索してもそれらしい記事が出てこないので、プロバイダー無いのかも??
https://registry.terraform.io/browse/providers

無ければ作れば良いんだよの精神

ということで、タイトルにもある通り、OpenMetadataのCustom Providerを作ることにしました。

はい。前置きが長くなりましたが、この記事はTerraformちょっと触ったことのある私がノリと勢いでCustom Providerを作ってみたお話です。
基本的に以下のチュートリアルを実践した後に、OpenMetadata用のカスタムプロバイダーを作りました。
https://developer.hashicorp.com/terraform/tutorials/providers-plugin-framework/providers-plugin-framework-provider

1. Custom Providerの仕組みについて

TerraformはTerraform CoreTerraform Providerにより構成されています。

1-1. Terraform Core

Terraform CoreはGo言語で書かれた静的にコンパイルされたバイナリです。
コンパイルされたバイナリはコマンドラインツール(CLI)であるterraformであり、Terraformを利用するためのエントリーポイントとして動作します。
その他、Terraform Coreはリソースの現在の状態を管理し、計画ファイル(terraform.plan)や状態ファイル(terraform.state)を操作します。
これにより、Terraformはどのリソースが作成されたか、どのリソースが変更されたか、そしてどのリソースが削除されたかを追跡します。

Terraform Coreの役割

  • コードとしてのインフラストラクチャ: 構成ファイルとモジュールの読み取りと補間
  • リソース状態管理
  • リソースグラフの構築
  • 計画の実行
  • RPC 経由のプラグインとの通信

1-2. Terraform Provider

Terraform Providerは、Terraformと外部のAPIやインフラストラクチャサービス(今回の場合はOpenMetadata)との間のインターフェースを提供します。
インタフェースを経由し、インフラ上の実際のリソースの作成、更新、削除、読み取りの処理を行います。

1-3. Terraform Plugin

Terraform Pluginは、APIクライアントの初期化や、特定のサービスに対する認証の管理を行います。この初期化処理には、必要な設定や認証トークンの取得、通信プロトコルの設定などが含まれます。

Terraform Pluginの役割

  • API呼び出しを行うために使用されるすべてのライブラリの初期化
  • インフラストラクチャプロバイダーによる認証
  • 特定のサービスにマップする管理対象リソースとデータソースを定義する
  • 実務者の構成の計算ロジックを有効にしたり簡素化したりする関数を定義する

上記役割をもう少し噛み砕くと、以下の流れで動作します

  1. terraform planを実行すると、Terraform Coreはプロバイダーに対して、計画されたリソースの状態を問い合わせ、変更点を表示します
  2. プランに基づいて、プロバイダーがリソースの操作を定義します
  3. terraform applyを実行すると、プロバイダーはリソースの操作を実際に行い、その結果をTerraform Coreに返します
  4. Terraform Coreはその結果を受け取り、状態ファイルを更新します

2. Custom Providerの設計原則

ここではCustom Providerを設計する際の、原則を紹介します。
これ以降でCustom Providerを作成するのですが、その際はこの設計原則に基づいて作成します。
https://developer.hashicorp.com/terraform/plugin/best-practices/hashicorp-provider-design-principles

2-1. プロバイダーは単一のAPIまたは問題領域に焦点を当てる

Custom Providerを作成する際は、単一のAPIに焦点を当てます。
例えば、AWS ,GCP ,Kubernetes, etcなどです。
単一のAPIに絞ることで、次のような利点があります。

  • プロバイダーの接続性と認証要件の簡素化
  • 実務者にとっての発見を簡素化
  • 関連システムや依存システムを新しい革新的な方法で構成できるようにする
  • メンテナーが単一のシステムまたはエコシステムの専門家になれるようにします

では逆にバッドケースはどのようなものでしょうか?

  • 汎用APIプロバイダー
    AWS ,GCPを組み合わせた汎用的なプロバイダー
  • 複数の問題領域をカバーするプロバイダー
    ネットワークやサーバのプロビジョニングなどの領域を1つのプロバイダーでカバーする

などが考えられます。

2-2. リソースは単一のAPIオブジェクトを表す必要がある

Terraformリソースは、通常、作成、読み取り、削除、およびオプションで更新メソッドを備えた単一のコンポーネントの宣言的表現である必要があります。
Terraformのリソースは具体的な作成オブジェクトと一対一の関係性になります。
例えば、main.tfでユーザ作成のresourceを記述したなら、ユーザ作成を呼び出すエンドポイントと一対一の関係になるよう推奨しています。
単一のリソースで複数のAPIリクエストをまとめて行うような複雑な操作は、リソース自体で行うのではなく、Terraformモジュールとして構成するべきです。
モジュールは、複数のリソースを組み合わせた抽象化を提供し、それらを組み合わせて一つのまとまりとすることで、ユーザーはより簡潔に複雑なインフラストラクチャを管理できるようになります。
リソースを単一のAPIオブジェクトに絞ることで、次のような利点があります。

  • 関連コンポーネントや依存コンポーネントを新しい革新的な方法で構成できるようにする
  • 予測可能性を最大化し、書き込み/削除操作の影響範囲を最小化する
  • 複数の基盤コンポーネントを管理するメンテナーの負担を防止します
    これは、Terraform プラグイン SDK のネイティブ設計パターンではありません

2-3. リソースと属性のスキーマは、基盤となるAPIと厳密に一致している必要がある

Terraform リソースと関連スキーマは、ユーザー エクスペリエンスを低下させたり、Terraform に対するユーザーの期待に反する動作をしない限り、APIの命名と構造に従う必要があります。
つまり、APIとTerraformのSchemaで定義する構造を一致させるということです。
例えば、API側にID,Name,Emailなどのフィールドを持つならば、それはTerraformのSchemaにも定義することを推奨しています。

スキーマを一致させることで、次のような利点があります。

  • 運用チームは、Terraform 固有の用語や処理を学習することなく、同じAPIと対話する複数のツールから簡単に変換したり、それらを利用したりできるようになります。
  • Terraform 固有の抽象化と将来の API 変更によって発生する予期しない名前の衝突を防止します。
  • Terraform 固有の抽象化による運用者の負担を防ぎます。
  • 部分的または完全なコード生成を容易にします。

さらに注意事項として

  • 日付と時刻は可能な限り常にRFC 3339でモデル化されるべきである。
  • Terraform は再帰型をサポートしていません (ただし、場合によっては動的型を使用してモデル化できます)
  • ブール属性は、true が何かを実行することを意味し、false が何も実行しないことを意味するように方向付けられるべきであり、これは API を反転することを意味する場合がある。

2-4. リソースはインポート可能である必要がある

terraform importをサポートする必要があります。

利点は次のとおりです。
ユーザーは手動と自動のプロビジョニングを組み合わせることができ、ブラウンフィールド環境で運用しているユーザーはプロバイダーを引き続き活用できます。

2-5. 状態とバージョン管理を考慮する

Terraformプロバイダーをバージョン管理し、リリースの際にセマンティックバージョニングを適用することを推奨します。
作成したCustom Providerをリリースすると、ユーザーはそのプロバイダーを使用してインフラ管理を行います。
日々の運用であらゆる不具合や要望が出た際にプロバイダーの修正や更新が必要になると思います。
その際に、更新したプロバイダーが既存のTerraformの状態に影響を与えないことや、下位バージョンと互換性を保つ必要があります。
そうでなければ、修正の影響でユーザからのクレームが出ることになるでしょうし、Terraform管理にも不具合が生じます。

また、どうしても大きな変更が必要な場合は、段階的な移行をサポートすることを推奨します。
そうすることで、ユーザ側に変更に備える時間を確保できます。

2-6. 関数は単一の計算操作を表す必要がある

プロバイダー定義の関数には、単一の計算目的で定義する必要があります。
「オプション」引数を介して公開される関数内の条件付きロジックではなく、個別の関数を使用することをお勧めします。
例えば、単純な税込金額を算出するような関数です。複雑なロジックを持つ関数は避けましょう。
この方法の利点は次のとおりです。

  • 作業者にとっての機能結果の予測可能性を最大化する
  • エコシステム全体でさまざまな「オプション」の議論スタイルを管理する実務者の負担を防ぐ
  • 複数のロジックの分岐を管理するメンテナーの負担を防ぐ

2-7. 関数は純粋かつオフラインであるべきである

プロバイダー定義の関数は、同じ引数に対して常に同じ結果を生成する必要があります。
関数は、環境ベース、時間ベース、またはネットワークベースのロジックを避ける必要があります。
代わりに、プロバイダー構成にアクセスし、Terraform の操作グラフに完全に参加できるため、これらの実装にはデータ ソースを優先します。
関数をオフラインにすることで、次の利点があります。

  • 実務家にとっての機能結果の予測可能性を最大化する
  • Terraformがどこでも構成全体を静的に検証できることを保証する
  • Terraformコマンド間で環境が変わった場合に発生する問題を防ぐ
  • ネットワークやサービスが利用できなくなった場合に専門家の問題を防ぐ

3. 実践Custom Provider作成

APIクライアントの作成

ここまで辛抱強く呼んでくれた人には想像できると思いますが、OpenMetadataへAPI処理をするクライアントありませんので、まずはAPIクライアントの自作から始まります。
Custom Providerチュートリアルで出てきたHashicups-Clientを参考に自作します。
https://github.com/hashicorp-demoapp/hashicups-client-go/tree/main

Custom Provider作成

私自身がGo言語初心者ということもあり、チュートリアルで作成したコードをベースにOpenMetadata用に書き換えるという方針で作成しました。
チュートリアルのコードのGithubを紹介しておきます。
https://github.com/hashicorp/terraform-provider-hashicups/tree/main

4. 作成したCustom Provider動作確認

今回は時間が無くてユーザ管理のリソースしか作れなかったのはご容赦ください。。。

ユーザの新規作成

ユーザ新規作成時のmain.tfです。
一応providerのhostとTokenは環境変数からも呼び出し可能になっています。

main.tf
terraform {
  required_providers {
    openmetadata = {
      source = "hashicorp.com/dev/openmetadata"
    }
  }
}

provider "openmetadata" {
  host     = "http://192.168.0.19:8585"
  token = "Token"
}

resource "openmetadata_createuser" "edu" {
    display_name = "terraform_user"
    email        = "terraform@example.com"
    name         = "terraform"
}

output "edu_user" {
  value = openmetadata_createuser.edu
}

terraform planを実行すると、

terraform applyでユーザを作成実行します。

yesを押すと、無事ユーザを作成できました。

OpenMetadata側でも確認してみます。
CurlでユーザIDを指定してGETした結果です。

ユーザ情報のアップデート

main.tfにdescriptionを追加しました。

resource "openmetadata_createuser" "edu" {
    display_name = "terraform_user"
    email        = "terraform@example.com"
    name         = "terraform"
    description  = "terraform_desc" ★ここを追加
}

terraform applyを実行し更新します。
内容もin-placeとUpdate処理となっていることがわかります。

問題ないので「yes」で実行します。
無事更新ができました。

再度CurlコマンドでOpenMetadata側を確認します。
Descriptionに「terraform_desc」と設定されています。

ユーザ削除処理

main.tfからユーザ設定を削除後、terraform applyを実行すると無事削除ができました。

自動受け入れテスト機能を追加

Terraformで定義する各処理(Read/Create/Update/Delete)をテストする機能を追加します。
テスト機能の追加もチュートリアルがあるので、コードを流用します。
https://developer.hashicorp.com/terraform/tutorials/providers-plugin-framework/providers-plugin-framework-acceptance-testing
今回はCreateとUpdate処理のテスト機能を実装しました。
これで、一々terrafomコマンドで動作確認しなくて済みます。

テストコード全体
package provider

import (
	"testing"

	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)

func TestAccOrderResource(t *testing.T) {
	resource.Test(t, resource.TestCase{
		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
		Steps: []resource.TestStep{
			// Create and Read testing
			{
				Config: providerConfig + `
					resource "openmetadata_createuser" "test" {
						name = "terraform2024"
						email = "terraform2024@example.com"
						display_name = "terraform_disp"
						description = "terraform_desc"
					}
					`,
				Check: resource.ComposeAggregateTestCheckFunc(
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "name", "terraform2024"),
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "email", "terraform2024@example.com"),
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "display_name", "terraform_disp"),
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "description", "terraform_desc"),
					// Verify dynamic values have any value set in the state.
					resource.TestCheckResourceAttrSet("openmetadata_createuser.test", "id"),
					resource.TestCheckResourceAttrSet("openmetadata_createuser.test", "last_updated"),
				),
			},
			// Update and Read testing
			{
				Config: providerConfig + `
					resource "openmetadata_createuser" "test" {
						name = "terraform2024"
						email = "terraform2024@example.com"
						display_name = "terraform_disp"
						description = "terraform_desc_test_update"
					}
					`,
				Check: resource.ComposeAggregateTestCheckFunc(
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "name", "terraform2024"),
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "email", "terraform2024@example.com"),
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "display_name", "terraform_disp"),
					resource.TestCheckResourceAttr("openmetadata_createuser.test", "description", "terraform_desc_test_update")),
			},
			// Delete testing automatically occurs in TestCase
		},
	})
}

実際にテストを実行する

% TF_ACC=1 go test -count=1 -run='TestAccOrderResource' -v
=== RUN   TestAccOrderResource
--- PASS: TestAccOrderResource (3.33s)
PASS
ok      terraform-provider-openmetadata/internal/provider       3.939s

OpenMetadataのログでテストが実行されたことを確認する

OpenMetadataのログより抜粋し、テストに記載の動作をしていることがわかります。

INFO [2024-09-18 01:56:26,102] [dw-28275 - POST /api/v1/users/] o.o.s.j.EntityRepository - Created user:add14355-6c38-4a50-8cec-0e4dbb42a105:terraform2024
INFO [2024-09-18 01:56:27,070] [dw-33426 - PATCH /api/v1/users/add14355-6c38-4a50-8cec-0e4dbb42a105] o.o.s.j.EntityRepository - Updated user:add14355-6c38-4a50-8cec-0e4dbb42a105:terraform2024
INFO [2024-09-18 01:56:27,944] [dw-28275 - DELETE /api/v1/users/name/terraform2024?hardDelete=true] o.o.s.j.EntityRepository - Hard deleted terraform2024

ちなみに、テスト機能には他にもFunction機能のテストも実施が可能です。

ドキュメントを生成する

terraform-plugin-docsを使って、これまで作成してきたプロバイダー、データソース、リソース、関数のドキュメントを生成できます。
最終的に生成されるドキュメントは以下のスクショの通りです。

terraform-plugin-docsは以下の命名規則に従って自動でドキュメントファイルを含みます。
そのため、ドキュメントを記述する以下4つのリソースはそれぞれ命名規則に従ったファイルパスに格納する必要があります。

  • Provider : examples/provider/provider.tf
  • Resource : examples/resources/{TYPE}/resource.tf
  • DataSource: examples/data-sources/{TYPE}/data-source.tf
  • Function : examples/functions/{TYPE}/function.tf

terraform-plugin-docsを追加

https://github.com/hashicorp/terraform-plugin-docs
tfplugindocsを使えるようにする方法は次の2つあります。

  1. tfplugindocsのバイナリを手動インストールする
  2. tools.goモデルを使う

今回はtools.goを使った方法をご紹介します、
これまでと同じようにチュートリアルの手順通りに進めます。
https://developer.hashicorp.com/terraform/tutorials/providers-plugin-framework/providers-plugin-framework-documentation-generation#add-resource-import-documentation

プロバイダーにドキュメントを追加

TerraformコマンドでOpanMetadataとのクライアント通信を確立するための設定例のドキュメントを追加します。

provider "openmetadata" {
  host  = "http://192.168.0.19:8585"
  token = "{Can also be set in the OPENMETADATA_TOKEN environment variable}"
}

provider.goにDescription値を追加します。

func (p *openmetadataProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
	resp.Schema = schema.Schema{
		Description: "Interact with OpenMetadata.",
		Attributes: map[string]schema.Attribute{
			"host": schema.StringAttribute{
				Description: "HOST for OpenMetadata API. May also be provided via OPENMETADATA_HOST environment variable.",
				Optional:    true,
			},
			"token": schema.StringAttribute{
				Description: "Token for OpenMetadata API. May also be provided via OPENMETADATA_PASSWORD environment variable.",
				Optional:    true,
				Sensitive:   true,
			},
		},
	}
}

Schemaにドキュメントを追加

ユーザのリソース作成定義をしているcreate_user_resource.goファイルのSchemaにも各フィールドのドキュメントを記載します。

func (r *createUserResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Description: "Fetches the list of Users.",
		Attributes: map[string]schema.Attribute{
			"id": schema.StringAttribute{
				Description: "Numeric identifier of the order.",
				Computed:    true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
			"email": schema.StringAttribute{
				Description: "User Email for login.",
				Required:    true,
			},
			"name": schema.StringAttribute{
				Description: "User unique name for OpenMetadata.",
				Required:    true,
			},
      ~略~
    }
  }
}

ユーザ作成時のmain.tfのサンプルコードのドキュメントを追加

examples/resources/{TYPE}/の下にresource.tfを作成し、以下の内容を記述します。

resource "openmetadata_createuser" "edu" {
  name         = "terraform"
  email        = "terraform@example.com"
  display_name = "terraform_disp2024"
  description  = "terraform_desc"
  passwaord    = "hogehoge"
}

Importのドキュメントを追加

resource.tfと同階層にimport.shというファイル名で以下の内容を記述します。

# User can be imported by specifying the numeric identifier.
terraform import openmetadata_createuser.example {UserID}

tools.goにドキュメントをビルドする設定を追加

tool/ディレクトリ配下にtools.goを作成し、以下内容を記述します。

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

//go:build generate

package tools

import (
	_ "github.com/hashicorp/copywrite"
	_ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs"
)

// Generate copyright headers
//go:generate go run github.com/hashicorp/copywrite headers -d .. --config ../.copywrite.hcl

// Format Terraform code for use in documentation.
// If you do not have Terraform installed, you can remove the formatting command, but it is suggested
// to ensure the documentation is formatted properly.
//go:generate terraform fmt -recursive ../examples/

// Generate documentation.
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir .. -provider-name openmetadata

go generateコマンドで静的なドキュメントを生成します。
generateコマンド実行後、docsディレクトリにマークダウンのドキュメンテーションが生成されます。

% go generate ./..
rendering website for provider "openmetadata" (as "openmetadata")
exporting schema from Terraform
compiling provider "openmetadata"
using Terraform CLI binary from PATH if available, otherwise downloading latest Terraform CLI binary
running terraform init
getting provider schema
generating missing templates
generating missing resource content
generating new template for "openmetadata_createteam"
generating new template for "openmetadata_createuser"
generating missing data source content
generating missing function content
generating missing provider content
generating new template for "openmetadata"
rendering static website
cleaning rendered website dir
removing file: "index.md"
removing directory: "resources"
rendering templated website to static markdown
rendering "index.md.tmpl"
rendering "resources/createteam.md.tmpl"
rendering "resources/createuser.md.tmpl"

今後の課題

正直、今回ユーザのみのプロバイダーリソースを作成しましたが、やり残した部分があるのでそれは今後の課題です。
1つは、PATCH処理の実装です。実はリソース作成時に設定値を記述したフィールド(上記ユーザ作成でいえば、Display_Name)はPUT処理での更新をサポートしていません。
その場合は、PATCHメソッドで更新する必要があります。
今回作成したプロバイダーにPATCHメソッドによる部分更新は未実装なので、今後実装したいです。
20240915追記により対応済み
2つ目は、本当に最低限動くだけを目指して作成したので、Go言語のイディオム的に違反している部分とか結構あるので、そこを修正したいです。
3つ目は、チームも作って、そのチームにユーザを所属させる際にTerraformのリソースIDを指定して所属させるようにしたい。20240920追記により対応済み

カスタムプロバイダーに入門してみた感想

1リソースを作るだけでも、まぁまぁ大変だったので、管理したいリソース分のプロバイダーを作るとなると結構辛い部分があると思います。
ただ、できることならカタログツールもTerraformで管理できると開発運用チームとしては助かる部分はあると思います。
公式のプロバイダーが出るのを待つ間は、自作プロバイダーで運用管理するというのもアリではないでしょうか。
チーム内にGo言語に詳しい人がいれば、かなり導入障壁は下がると思います!

最後に

今回、Go言語初心者が分からないなりにも試行錯誤して、Custom Providerを1リソース分を作成してみました。
これまでなんと無くで動かしていたTerraformの動作原理などを理解できた点は非常に良かったと思います。
Terraformのことをもっと知りたいと思われたら是非Custom Provider作成にチャレンジしてみて下さい!!

注意ポイント

  • OpenMetadataのバグで躓いた話
    main.tfのName値がEmailの@の前半部分と完全一致していないと、terraformコマンドがコケます。
    原因はNameとEmailの前半部分が一致していないことだと思います。
    そこはフレームワーク側でエラーハンドリングされていました。
    さらにOpenMetadata v1.4.3では、POSTメソッドのBodyに@の前半と異なる値をNameに設定しても、POSTが成功します。
    しかも、実際に設定される値はEmailの前半部分となります。
    なので、APIクライアントでユーザを取得するメソッドを書いている時に、Nameの検索で404 Not Foundが出るエラーと戦ってました。。。

20240915追記

PATCH処理の実装

今後の課題であげていた、PATCH処理の実装をしてみました。
チュートリアルにはPATCH処理の実装については記載がありませんでしたので、私のオリジナル実装となっています。
なので、色々と甘々な実装となっています。。。。
是非この辺りのお話は有識者とディスカッションしたいです。
あと、Terraform Plugin FrameworkでPATCHをよしなに処理してくれないかドキュメントを読みましたが見つからず。。。
もしかしたらPATCH処理を自作する必要は無い可能性もあります。
自作するとしたら、terraform.stateファイルとmain.tfの データ差分を取得し、その差分構成によってPATCHリクエストをクライアントへ投げるようにしました。

PATCH処理の流れ

  1. 現在の状態の取得: リソースの現在の状態をTerraformの状態ファイルから取得します。
  2. 計画された変更と比較: ユーザーが変更を加えた後のリソースの状態と現在の状態を比較します。
  3. パッチデータの生成: 差分に基づいてPATCHリクエストに含めるJSONデータを生成します。
  4. PATCHリクエストの送信: 最後に、生成されたパッチデータを使ってAPIにPATCHリクエストを送信します。

PATCHリクエスト種別
OpenMetadataのSwaggerより、PATCHメソッドがサポートしている処理は次の3パターン{add/replace/remove}です。
それらを以下のようにデータ差分パターンに分けて処理を行うようにしました。

main.tfに存在してterraform.stateファイルに存在しない場合:add
main.tfに存在し、terraform.stateファイルと値が異なる場合:replace
main.tfに存在せず、terraform.stateファイルにのみ存在する場合:remove

簡単にPATCH処理のコードをご紹介します。
次の2つのコードから現在と変更後のリソースの状態を取得します。

terraform.stateファイルから現在の状態を取得する
var plan_data patchUserModel
diags := req.Plan.Get(ctx, &plan_data)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
  return
}
main.tfで変更を加えた状態の情報を取得する
var state_data createUserResourceModel_res
diags = req.State.Get(ctx, &state_data)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
  return
}

その後PATCHリクエスト種別にあるように差分パターン毎にリクエストを作成します。

main.tfのdescription値を削除した時のTerraformの動き

- description = "terraform_desc2024" -> nullの部分で、descriptionの値がNullになっています。
また最終的なstate情報OutPuts:でも、descriptionの値がNullになっています。

$ terraform apply
╷
│ Warning: Provider development overrides are in effect
│
│ The following provider development overrides are set in the CLI configuration:
│  - hashicorp.com/dev/openmetadata in /Users/kyamisama/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.
╵
openmetadata_createuser.edu: Refreshing state... [id=d98a2e27-763e-4bb8-8f8b-165f330130a7]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # openmetadata_createuser.edu will be updated in-place
  ~ resource "openmetadata_createuser" "edu" {
      - description  = "terraform_desc2024" -> null
        id           = "d98a2e27-763e-4bb8-8f8b-165f330130a7"
      ~ last_updated = "Sunday, 15-Sep-24 21:06:55 JST" -> (known after apply)
        name         = "terraform"
        # (2 unchanged attributes hidden)
    }

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

Changes to Outputs:
  ~ edu_user = {
      ~ description  = "terraform_desc2024" -> null
        id           = "d98a2e27-763e-4bb8-8f8b-165f330130a7"
      ~ last_updated = "Sunday, 15-Sep-24 21:06:55 JST" -> (known after apply)
        name         = "terraform"
        # (5 unchanged attributes hidden)
    }

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

openmetadata_createuser.edu: Modifying... [id=d98a2e27-763e-4bb8-8f8b-165f330130a7]
openmetadata_createuser.edu: Modifications complete after 0s [id=d98a2e27-763e-4bb8-8f8b-165f330130a7]

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

Outputs:

edu_user = {
  "description" = tostring(null)
  "display_name" = "terraform_disp2-24"
  "email" = "terraform@example.com"
  "id" = "d98a2e27-763e-4bb8-8f8b-165f330130a7"
  "last_updated" = "Sunday, 15-Sep-24 21:11:39 JST"
  "name" = "terraform"
  "roles" = tostring(null)
  "teams" = tostring(null)
}

素人ながらPATCH処理を実装し、期待する動作とはなりましたが、ロジック的に他の方の意見が欲しいところだと思いました。
1人で作るのも限界ですね・・・・

20240920追記

main.tfで定義したTeamにユーザを所属させる処理の実装

main.tfで定義したチームにユーザを所属させる処理の実装を行いました。
イメージとしては、以下のようなmain.tfのことを指します。

main.tf
resource "openmetadata_createuser" "edu" {
  name         = "terraform"
  email        = "terraform@example.com"
  display_name = "terraform_disp2024"
  description  = "terraform_desc"
  teams        = [openmetadata_createteam.edu.id] ★この記述でterraformユーザがtestTeamに所属するよう実装する
}

resource "openmetadata_createteam" "edu" {
  name         = "testTeam"
  teamtype     = "Group"
  description  = "testDesc"
  display_name = "testDisp"
}

処理の実装自体は難しく無く、Terraformの基本動作に沿って実装しました。
main.tfで定義した内容でクライアントAPIをコールし、その結果をGETメソッドなどで現状の状態を取得しterraform.stateに保存する流れです。

躓いたポイント①

GETメソッドを呼び出して、ユーザの情報を取得する際に何度やってもTeamsの情報が取得できませんでした。
ユーザIDを指定してGETメソッドを実行した結果は以下の通りです。

{
  "id": "f40054a6-dcde-4b87-a318-03bcab048cf0",
  "name": "kyami22",
  "fullyQualifiedName": "kyami22",
  "description": "hogehogepatch",
  "version": 2.5,
  "updatedAt": 1726833879150,
  "updatedBy": "c373ism",
  "email": "kyami22@example.com",
  "href": "http://192.168.0.19:8585/api/v1/users/f40054a6-dcde-4b87-a318-03bcab048cf0",
  "isBot": false,
  "isAdmin": false,
  "personas": [],
  "changeDescription": {
    "fieldsAdded": [
      {
        "name": "teams",
        "newValue": "[{\"id\":\"04a65422-d286-4a80-a0d7-885face3a243\",\"type\":\"team\",\"name\":\"testTeamA\",\"fullyQualifiedName\":\"testTeamA\",\"displayName\":\"testDispA\",\"deleted\":false}]"
      }
    ],
    "fieldsUpdated": [],
    "fieldsDeleted": [],
    "previousVersion": 2.4
  },
  "deleted": false
}

changeDescriptionでteamsがfieldsAdded(追加)されているのは確認できるのですが、そもそもteamsフィールド情報が取得できません。
Swaggerを再度確認したところ、以下のフィールドはクエリパラメータで指定をしないと取得できないようです。

  • profile
  • roles
  • teams
  • follows
  • owns
  • domains
  • personas
  • defaultPersona

などなど・・・

例として、以下にクエリパラメータにteamsを追加した結果を載せます。
無事teamsフィールド情報を取得できました。
terraform.stateファイルにユーザがどのチームIDに所属しているかを保持させるためにteams情報を取得する必要がありました。

クエリパラメータにteamsを追加
{
  "id": "f40054a6-dcde-4b87-a318-03bcab048cf0",
  "name": "kyami22",
  "fullyQualifiedName": "kyami22",
  "description": "hogehogepatch",
  "version": 2.5,
  "updatedAt": 1726833879150,
  "updatedBy": "c373ism",
  "email": "kyami22@example.com",
  "href": "http://192.168.0.19:8585/api/v1/users/f40054a6-dcde-4b87-a318-03bcab048cf0",
  "isBot": false,
  "isAdmin": false,
  "teams": [
    {
      "id": "3f9ddb39-84b2-40cd-a5a2-d2e50ca1f478",
      "type": "team",
      "name": "test_tean",
      "fullyQualifiedName": "test_tean",
      "description": "",
      "displayName": "test_team",
      "deleted": false,
      "href": "http://192.168.0.19:8585/api/v1/teams/3f9ddb39-84b2-40cd-a5a2-d2e50ca1f478"
    }
  ],
  "personas": [],
  "changeDescription": {
    "fieldsAdded": [
      {
        "name": "teams",
        "newValue": "[{\"id\":\"04a65422-d286-4a80-a0d7-885face3a243\",\"type\":\"team\",\"name\":\"testTeamA\",\"fullyQualifiedName\":\"testTeamA\",\"displayName\":\"testDispA\",\"deleted\":false}]"
      }
    ],
    "fieldsUpdated": [],
    "fieldsDeleted": [],
    "previousVersion": 2.4
  },
  "deleted": false
}

躓いたポイント②

ユーザ情報Terraformで作成する際、teamsやrolesなどは複数のリソースをユーザが保持することができます。
例えば、ユーザAはチームAとチームBに属するやロールAとロールBを保持する。などです。
つまり、所属または保持するリソースが可変となります。
なので、Goの構造体でteamsやrolesはスライス型で定義しました。
以下のようなイメージです。

type createUserModel struct {
	ID          types.String   `tfsdk:"id"`
	Name        types.String   `tfsdk:"name"`
	Email       types.String   `tfsdk:"email"`
	DisplayName types.String   `tfsdk:"display_name"`
	Description types.String   `tfsdk:"description"`
	Password    types.String   `tfsdk:"password"`
	Roles       []types.String `tfsdk:"roles"`
	Teams       []types.String `tfsdk:"teams"`
	LastUpdated types.String   `tfsdk:"last_updated"`
}

ここからが私が躓いた初歩的なミスなのですが、Update処理で最初はチームA、Bに属していたが、チームBを削除するケースをテストしたところ、
terraform.stateファイルの更新が以下のようになりました。
見ていただくと分かるように、fbf199f1-1955-463e-a0bc-6ac593e08639が重複しています。

"teams": [
  "fbf199f1-1955-463e-a0bc-6ac593e08639",  //これがチームA
  "78bd7275-09e2-4ca8-bde5-de1ae67184c6", //これがチームB 
  "fbf199f1-1955-463e-a0bc-6ac593e08639"   //なぜかチームAが追加され重複している
]

重複した原因はTeamsをスライス型で定義したので、最初の構築時のデータが積み上がったためでした。
なので、terraform.stateファイルへ保持する際は構造体を初期化することで対応しました。

Discussion