エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

自作 Terraform Registry

こちらはエムスリー Advent Calendar 2022 Advent Calendar 2022の一日目の記事です。

エムスリーエンジニアリンググループ AI・機械学習チームでソフトウェアエンジニアをしている中村(po3rin) です。検索とGoが好きです。今回はPrivate Terraform Registry周りを調べていて学んだTerraform Provider Registryを自作する方法を紹介します。

Overview

Private Terraform Registry周りについて調べていたら下記の面白そうな文を発見しました。

developer.hashicorp.com

Terraform can use versioned modules from any service that implements the registry API. The Terraform open source project does not provide a server implementation, but we welcome community members to create their own private registries by following the published protocol.

つまりregistry APIを実装すればTerraform Registryを自作できるよとのこと。

これはちょっと触ってみたいと思い、今回小さなTerraform Registryを自作してみました。この実装を通してTerraformのProvider importの仕組みを理解できたので、Terraformの勉強にもいいと思います。

あまりないと思いますが、HashiCorp Terraform Registryが社内で規定されているセキュリティ懸念で使えずにオンプレにどうしてもProviderをuploadしたい時などにも有用です。

Terraform Providerについて

TerraformでProviderを利用する際にはterraform initを行い外部のTerraform Registryからインストールします。最も利用されているのはHashiCorpが提供するTerraform Registryです。

下記のような宣言でProviderをinstallできます。defaultではHashiCorpが提供するTerraform Registryからinstallしてきます。

terraform {
  required_providers {
    berglas = {
      source = "hirosassa/berglas"
      version = "0.2.1"
    }
  }
}

Terraform Registry API

基本的には先ほど紹介したTerraform RegistryからProviderをinstallしてきますが、実はTerraform Registry APIを実装している任意のAPIからProviderをinstallできます。下記のようにsourceにURLを含めた指定をします。

terraform {
  required_providers {
    berglas = {
      source = "<任意のAPIのURL>/hirosassa/berglas"
      version = "0.2.1"
    }
  }
}

実装すべきAPIは下記のドキュメントに記載があります。

developer.hashicorp.com

実装すべきは下記の3つです。

  • Service Discovery: Terraformのリモートサービス検出

  • List Available Versions: 特定のProviderで現在使用可能なバージョンを返す

  • Find a Provider Package: 特定のOS/アーキテクチャのProviderのダウンロードURLやメタデータなどを返す

この3つを実装すればあとは任意の管理方法でProviderを提供できます。今回はGoによる実装を通してどのようにRegistryを自作するのかを紹介していきます。

GoによるTerraform Provider Registry実装

今回はGoを使った実装を紹介します。今回はサクッと実装したいためWEBフレームワークのGinを使います。

アーキテクチャは下記になります。先ほど紹介した3つのAPIに加えてProviderを登録するAPIの計4つのAPIを実装します。

今回はデータベースを使わずディスクにファイルとして保存する形の実装をします。Find a Provider Package APIで返すパブリックPGPキーもディスクに保存します。

PGPプライベートキーを使ってGitHub Actions上でProviderをビルドして、そのURLやメタデータをAPI経由で登録します。社内利用であれば一つのPGPキーだけで充分なので、このままクラウドなどにリリースすればプライベートリポジトリとして利用できます。

実装は公開しているので参考までに

https://github.com/po3rin/pon-tf-registrygithub.com

まずはGinのルーターを初期化します。

package main

import  "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()
    r.GET("/.well-known/terraform.json", wellknown)
    r.GET("/v1/providers/:namespace/:name/versions", listVersions)
    r.GET("/v1/providers/:namespace/:name/:version/download/:os/:archi", download)
    r.POST("/v1/providers/:namespace/:name/:version/regist", regist)
    r.Run()
}

Service Discovery API

下記のエンドポイントを実装します。

GET /.well-known/terraform.json

こちらはドキュメントにある通りのJSONを返すだけです。

func wellknown(c *gin.Context) {
    c.JSON(200, gin.H{
        "providers.v1": "/v1/providers/",
    })
}

Regist Provider API

下記のエンドポイントを実装します。

POST /v1/providers/:namespace/:name/:version/regist

Regist Provider APIでは下記のようなJSONを受け取る実装にします。これらの情報は全てGitHubでリリースしたProviderの情報から取得可能です。

{
  "protocols": [
    "5.0"
  ],
  "os": "darwin",
  "arch": "amd64",
  "filename": "terraform-provider-berglas_0.0.1_darwin_amd64.zip",
  "download_url": "https://github.com/po3rin/terraform-provider-berglas/releases/download/v0.0.1/terraform-provider-berglas_0.0.1_darwin_amd64.zip",
  "shasums_url": "https://github.com/po3rin/terraform-provider-berglas/releases/download/v0.0.1/terraform-provider-berglas_0.0.1_SHA256SUMS",
  "shasums_signature_url": "https://github.com/po3rin/terraform-provider-berglas/releases/download/v0.0.1/terraform-provider-berglas_0.0.1_SHA256SUMS.sig",
  "shasum": "4b4112d8a26c7eff9e3ba98b831aee30b0337cc03a232c12099f7350cba6c5b2"
}

次にProvider登録APIを実装して行きます。まずはPGPパブリックキーをディスクから取得する関数を作ります。この情報はterraform initがPGPを使った改ざんのチェックを行うので必要です。少し長いのでエラーハンドリングは省略しています。

type PGPSigningKey struct {
    KeyID      string
    ASCIIArmor string
}

func GetPublicSigningKeyFromFile(pgpID string, pubKeyFile string) (*PGPSigningKey, error) {
    pubKey, _ = os.ReadFile(pubKeyFile)
    return &PGPSigningKey{pgpID, string(pubKey)}, nil
}

この関数を使ってProvider登録を実装します。下記のようなJSONを受け付ける実装をします。これらのデータはGitHubにリリースされたデータから作成できます。少し長いのでエラーハンドリングは省略しています。

func regist(c *gin.Context) {
    ns, _ := c.Params.Get("namespace")
    name, _ := c.Params.Get("name")
    version, _ := c.Params.Get("version")
    id := os.Getenv("PGP_ID")
    keyFile := os.Getenv("PGP_PUBLIC_SIGNING_KEY_FILE")

    signingKey, _ := GetPublicSigningKeyFromFile(id, keyFile)

    var p Provider
    _ = c.ShouldBindJSON(&p)

    signingKeys := SigningKeys{
        GpgPublicKeys: []GpgPublicKey{
            {
                KeyID:      signingKey.KeyID,
                AsciiArmor: signingKey.ASCIIArmor,
            },
        },
    }
    p.SigningKeys = signingKeys

    file, _ := json.MarshalIndent(p, "", " ")

    _ = os.MkdirAll(filepath.Join("provider", ns, name), os.ModePerm)

    _ = ioutil.WriteFile(
        filepath.Join("provider", ns, name, fmt.Sprintf("%s_%s_%s.json", p.OS, p.Arch, version)),
        file,
        0644,
    )
    c.JSON(200, "ok")
}

上記APIは受け取ったJSONにPGPパブリックキーを付与してディスクに保存します。

List Available Versions API

下記のエンドポイントを実装します。

/v1/providers/:namespace/:name/versions

こちらはディスクに保存されている情報からバージョンを抜き出してくるだけです。

func listVersions(c *gin.Context) {
    ns, _ := c.Params.Get("namespace")
    name, _ := c.Params.Get("name")

    file_names := dirwalk(fmt.Sprintf("provider/%s/%s", ns, name))

    versions := make([]map[string]any, len(file_names))
    for i, n := range file_names {
        rep := regexp.MustCompile(`.json$`)
        e := filepath.Base(rep.ReplaceAllString(n, ""))
        versions[i] = map[string]any{
            "version": strings.Split(e, "_")[2],
        }
    }

    c.JSON(200, gin.H{
        "versions": versions,
    })
}

Find a Provider Package API

下記のエンドポイントを実装します。

/v1/providers/:namespace/:name/:version/download/:os/:archi

こちらのAPIではディスクに保存してあるProviderのダウンロードURL、メタデータを返すだけです。

func download(c *gin.Context) {
    ns, _ := c.Params.Get("namespace")
    name, _ := c.Params.Get("name")
    version, _ := c.Params.Get("version")
    os_val, _ := c.Params.Get("os")
    archi, _ := c.Params.Get("archi")

    b, _ := os.ReadFile(filepath.Join("provider", ns, name, fmt.Sprintf("%s_%s_%s.json", os_val, archi, version)))
 
    res := map[string]any{}
    _ = json.Unmarshal(b, &res)
    c.JSON(200, res)
}

これで実装は完了です。基本的なAPIを実装するだけなら爆速ですね。

動作確認

まずはGPGキーを生成します。

gpg --full-generate-key

プライベートキーを確認します。

gpg --armor --export-secret-keys [key ID or email]

出力結果を対象GitHubリポジトリのシークレットに「GPG_PRIVATE_KEY」という名前で登録します。 パスフレーズを設定した場合は「PASSPHRASE 」も一緒に登録します。

下記のGitHub Actionsを利用すればtagを打つと同時にGitHubへのリリースが走ります。今回は動作確認用に弊社で利用しているterraform-provider-berglasをフォークしているので参考までに。

github.com

name: 'release'

on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: 'ubuntu-latest'
    steps:
      - uses: 'actions/checkout@v3'
        with:
          fetch-depth: 0

      - uses: 'actions/setup-go@v3'
        with:
          go-version: '1.19'

      - id: 'import_gpg'
        uses: 'crazy-max/ghaction-import-gpg@v5'
        with:
          gpg_private_key: '${{ secrets.GPG_PRIVATE_KEY }}'
          passphrase: '${{ secrets.PASSPHRASE }}'

      - uses: 'goreleaser/goreleaser-action@v3'
        with:
          version: 'latest'
          args: 'release --rm-dist'
        env:
          GPG_FINGERPRINT: '${{ steps.import_gpg.outputs.fingerprint }}'
          GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'

続いてPGPパブリックキーをpgp_publicという名前でファイルに書き出しておきます。これはProvider登録時に利用されます。

これで準備は完了です。早速ローカルで動作確認をしたいのですが、terraform initはHTTPSじゃないと失敗するので、今回はローカルでの動作確認にngrokを利用します。

ngrok.com

# 環境変数をよしなにセット
# PGP_ID=sample
# PGP_PUBLIC_SIGNING_KEY_FILE=pgp_public

$ go run ./...
$ ngrok http 8080

これでAPIが立ち上がりました。これで実際にTerraform ProviderをAPIからProviderを利用できることを確認しましょう。まずはProviderの登録です。JSONはGitHubリリースの情報から今回は手動で作ります。

curl -X POST -H "Content-Type: application/json" -d '@sample_request.json' 'localhost:8080/v1/providers/po3rin/berglas/0.1.1/regist'

続いてTerraformリソースを用意します。ドメインはngrokが生成したドメインになるので注意してください。

terraform {
  required_providers {
    berglas = {
      source  = "<sample>.jp.ngrok.io/po3rin/berglas"
    }
  }
}

これでterraform initを行い、実際にProviderをインストールできたことが確認できます。

Initializing the backend...

Initializing provider plugins...
- Finding latest version of <sample>.jp.ngrok.io/po3rin/berglas...
- Installing <sample>.jp.ngrok.io/po3rin/berglas v0.1.1...
- Installed<sample>.jp.ngrok.io/po3rin/berglas v0.1.1 (self-signed, key ID 05D0F12ED318A0D2)

// ...

Terraform has been successfully initialized!

Providerのlatest versionをList Available Versions APIから取得し、特定バージョンのProviderのメタデータをFind a Provider Package APIで取得し、PGPによる改ざんチェックをしたのちにdownload URLからProviderをインストールして、init完了となります。

Next Step

次のステップとして、ディスクに置いてあるデータをDBに保存するようにしたり、ユーザーごとにPGPパブリックキーを登録できるようにするなどが考えられます。また、今回はGitHubでリリースされた情報から登録するJSONを作りましたが、GitHubのリリースタグだけ指定すれば自動で情報をスクレイピングして、Providerとして保存してくれるような機能もあると便利です。

まとめ

今回はTerraform Provider Registryの実装を通してterraform initが実際にどのように Providerをインストールしているのかを学びました。

We are Hiring!

エムスリーでは日本の医療を前進させるために、Terraformなどで日々の運用改善をしていくメンバーを募集しています。

jobs.m3.com

参照

developer.hashicorp.com

developer.hashicorp.com