hashicorp/go-plugin を理解して Terraform provider の情報を得る
人間が Terraform の設定を書くときにはどんなリソースがあるか、どんなアトリビュートやブロックがあるかはドキュメントを読めば分かるけど、機械が Terraform の設定をチェックするときには構造化されたスキーマが欲しくなる。 Terraform の provider はどれも実行可能なバイナリのプラグインとして本体から分離されており、Terraform 本体がそのバイナリからスキーマ情報を得て型チェック等をしているはずなので、プラグインからどうやって情報を得ているのか調べた。 プラグインを書く側のドキュメントはまぁまぁあるんだけど、プラグインを起動する側のドキュメントはまとまったものがなくて調べるのに意外と苦労した……
hashicorp/go-plugin
Terraform や Packer 等の Hashicorp プロダクトのプラグインシステムには https://github.com/hashicorp/go-plugin が採用されている。 ざっくり言うと、プラグインを実行可能なバイナリとして表現し、バイナリを子プロセスとして実行すると gRPC サーバ *1 が起動するので、親プロセスはその gRPC サーバを介してプラグインとやりとりするようなアーキテクチャになっている。
プラグインの起動
起動方法は https://github.com/hashicorp/go-plugin/blob/master/docs/internals.md に書かれている…… と思いきや「You do not need to understand the internals of the handshake, unless you're building a go-plugin compatible plugin in another language」とのことで省略されている。
実装を読んでみると、handshake 用の特殊な環境変数と、プロトコルのバージョンを指定する環境変数を与えるとプラグインを起動できるようだった。
Terraform の場合は handshake 用の環境変数は https://pkg.go.dev/github.com/hashicorp/terraform-plugin-sdk/v2@v2.3.0/plugin#pkg-variables で定義されている。この場合、TF_PLUGIN_MAGIC_COOKIE=d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2
という環境変数を指定すれば OK。
これだけでもプラグインの起動には成功するが、プロトコルのバージョンも指定しないとちゃんと通信できない。
Terraform の場合は https://github.com/hashicorp/terraform/tree/master/docs/plugin-protocol にあるように、現在のプロトコルバージョンは 5 である。
よって Terraform プラグインを起動するときに必要な環境変数は PLUGIN_PROTOCOL_VERSIONS=5 TF_PLUGIN_MAGIC_COOKIE=d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2
になる。
プラグインとの通信
このように起動すると、プラグインは stdout に 1|5|unix|/tmp/plugin243689465|grpc|
のような1行を出力する。
これの意味はちゃんと https://github.com/hashicorp/go-plugin/blob/master/docs/internals.md に書いてある。CORE-PROTOCOL-VERSION は1固定、APP-PROTOCOL-VERSION は PLUGIN_PROTOCOL_VERSIONS に指定した通りの5、NETWORK-TYPE はおそらく UNIX 環境では unix 固定、NETWORK-ADDR は UNIX 環境では UNIX domain socket のパス、PROTOCOL は Terraform プラグインでは grpc 固定である。
この UNIX domain socket が gRPC サーバになっているので、gRPC でプラグインと通信できるようになる。
Terraform の場合はどういう gRPC サーバになってるかは後述。
プラグインの終了
これもとくにドキュメントを見つけられなくて実装を読んだ。シェルからプラグインを起動すると C-c が潰されていて特殊な終了方法があると気付きやすくなっていて気が利いてる (?)。 実はプラグインが起動する gRPC サーバはプラグインの種類毎に実装されたサービスだけでなく、GRPCBroker と GRPCController と呼ばれるサービスも共通で実装している。 このうち GRPCController が Shutdown() という RPC を持ってるので、これを呼ぶと終了できる https://github.com/hashicorp/go-plugin/blob/v1.4.0/internal/plugin/grpc_controller.proto 。 retruns Empty になってるけど Shutdown() を呼ぶと gRPC サーバも終了するので Empty のレスポンスを正常に受け取れることは無い。 正常に処理が進めば Shutdown() でプラグインプロセスも終了するはずだけど、hashicorp/go-plugin の実装では一定のタイムアウト後にプロセスを kill しているみたい。
Terraform プラグインの gRPC サーバ
ドキュメントとしては https://github.com/hashicorp/terraform/tree/master/docs/plugin-protocol にあるんだけど、実際に実装で使われてるのはこっち https://github.com/hashicorp/terraform-plugin-go/blob/v0.2.0/tfprotov5/internal/tfplugin5/tfplugin5.proto 。 Provider の GetSchema() を呼ぶと resource、data source の一覧を取得することができるので、これでようやく目的を達成できる。
type のシリアライゼーション
上記の proto を見ていると、Attribute の type が bytes 型になっていることに気付く。 ここには Terraform 上の型 *2 が JSON 形式でシリアライズされた値が入っており、シリアライズの方法は https://github.com/hashicorp/terraform/blob/master/docs/plugin-protocol/object-wire-format.md#schemaattribute-mapping-rules-for-json に書かれている。 なので GetSchema() の結果からアトリビュートの型を知りたい場合はこれに従ってデシリアライズすると分かる。
Terraform の service discovery
ここまでで Terraform プラグインのバイナリがあればそこから情報を引き出すことができるようになったので、あとは Terraform プラグイン自体を手に入れる必要がある。 Terraform プラグインは https://www.terraform.io/docs/internals/remote-service-discovery.html にある方法で見つけることができる。これはちゃんとドキュメントが書かれているのでその通りに実装すればいいだけ。 公式レジストリから手に入れるにはまず https://registry.terraform.io/.well-known/terraform.json にアクセスして providers.v1 のパスを知り、そこから https://www.terraform.io/docs/internals/provider-registry-protocol.html の API で見つければいい。
dump-tf-schema
ここまでを実装したのがこれ。
Terraform プラグインの理解の参考にどうぞ。
Go のコードを Rust から呼び出す
近年のツールは Go で実装されていることが多く [要出典]、CLI だけでなくライブラリとして API も公開されていることも多くてそのツールを自作のツールに組み込むことも容易になっている。 しかしそれはあくまで自作のツールも Go で書いている場合で、言語の壁を超えることは難しい。
と思ってたんだけど、cgo を使えば Go でわりと手軽に C ライブラリを作ることができて、C ライブラリの呼び出しは色々な言語でサポートされているので、C のインターフェイスを経由すれば思ってたよりは手軽に他言語から Go のコードを呼び出せることに気付いた。
これは https://pkg.go.dev/cuelang.org/go/cue を使って cue export
相当を Rust からできるようにした例。
C の貧弱な表現に合わせたりメモリ管理の手間があったり Rust の文字列へのコピーのオーバーヘッドがあったりするけど、bindgen を使えば FFI の宣言を自動生成できるので、これくらいのコード量で Rust から Go のコードを呼び出せるようにできた。
やってることとしては、build.rs の中で go build -buildmode=c-archive
を実行して .a と .h を生成し、.a を静的リンク対象に追加しつつ .h から bindgen で FFI の宣言を生成しているというかんじ。
Go の世界には C における const の概念が無いので、const char * を受け取るような関数を定義するときは typedef const char *const_string_t
みたいな定義を preamble に書いておいて C.const_string_t を受け取る関数として定義して C の世界に export すればいい。
preamble とか export とかを特殊なコメントとしてやる設計もやはりあまり好きになれない……
ここまでで crate として動くものはできるんだけど、この crate を publish しようとすると docs.rs の環境でビルドできないという問題がある。docs.rs のビルド環境ではネットワークアクセスが禁止されていて、build.rs の中から Go module のダウンロードができないからだ。
これを回避するには go mod vendor で依存モジュールのコードも含めてパッケージングして publish するしかない、と思う。
あと地味な問題として go build がデフォルトでビルドに使うディレクトリが docs.rs の環境では書き込みを禁じられているので、if std::env::var("DOCS_RS").is_ok() { cmd.env("XDG_CACHE_HOME", "/tmp/.cache"); }
みたいに XDG_CACHE_HOME を変えておく必要がある。
switch_point では ActiveRecord v6.1 以降をサポートしないことにした
switch_point を4年ぶりにリリースした。このリリースは主に ActiveRecord v6.1 以降をサポートしない意志を表明するためのものである。
switch_point は6年前に仕事で困ったことを解決するために書いた gem である。経緯は https://eagletmt.hateblo.jp/entry/2014/09/22/203819 を参照。この記事にある「Rails の激しい変更についていきやすい設計・実装」は成功したと思っていて、バージョンや respond_to? による分岐を一切せずに ActiveRecord v3.2 から v6.0 までサポートすることに成功している。
しかしまもなくリリースされるであろう ActiveRecord v6.1 では ActiveRecord::ConnectionAdapters::ConnectionPool#spec が消えて、ついに switch_point が壊れることが分かった。バージョン分岐を許容すれば直せそうな気がするが、しかし ActiveRecord v6.0 で本家に R/W Splitting 用の機能が入った https://guides.rubyonrails.org/active_record_multiple_databases.html 以上、switch_point を使えるようメンテする意味も無いと思い、ここで switch_point は終了させようと思った。
正直 v6.0 でこの機能が入った時点で switch_point は使えなくなるかと思っていたけど、運良く壊れずに済んでいた。よって ActiveRecord v6.0 では本家の機能と switch_point の両方を使えるので、switch_point からの移行パスとしてはまず ActiveRecord を v6.0 にし、この状態で switch_point から本家の機能へと移行することを想定している。
音楽サービス2020
自分は音楽の購入・管理サービスとして Google Play Music を使っていた。楽曲だけでなく、ドラマ CD だったり音声作品だったりにも Google Play Music を使っていた。しかし Google Play Music がサービスクローズになり、YouTube Music へと移行せざるをえなくなった。ストア機能が失われることに不満を感じつつも他に選択肢も無いので YouTube Music に移行したところ、音楽の再生に関する機能も若干使いづらい上に (Google ではなく) YouTube のアカウントと紐付いてしまって破滅したりアップロード機能が致命的に機能不足だったりと最悪の体験だった。あまりに最悪すぎて Apple Music へと徐々に移行していっているところなんだけど、そのへんの話を書いてみる。
音楽の購入・管理サービスの種別
自分にとって大きく分けて3つのコア機能があると思っている。
ストリーミングサービス
おそらく近年一番人気で需要が高いサービス。サブスクリプションサービスとも。定額の月額料金で対象の音楽をいくらでも聞けるというもの。この機能は Google Play Music にもあったし YouTube Music にもある。有名なサービスとしては Spotify、Apple Music、Prime Music や Amazon Music Unlimited あたりだろうか。ANiUTa のようにジャンルを絞って独自のラインナップを提供しているサービスもある。
ストアサービス
サブスクリプションサービスが一般的になるまで一般的だったもの。CD ではなく mp3 等のデータの状態で音楽を購入できるサービス。購入した音楽は専用のアプリでどこでも再生できるようになることが多い。 Google Play Music の頃は Play Store で音楽を買ってそれを Google Play Music 内でそのまま再生できた。Play Store で音楽を扱わなくなったので、YouTube Music ではこの機能が失われた。有名なサービスとしては iTunes Store、Amazon Music、mora あたりだろうか。DLsite や Bandcamp のようなものもここに含まれるかも。
ロッカーサービス
手元にある音楽データを預けて色々なデバイスで再生できるようにしてくれるもの。ストアサービスで購入したデータをアップロードしたり、CD から取り込んだデータをアップロードしたりして、主にスマートフォンから簡単に再生できるようにするような使い方が一般的だと思う。 Google Play Music にはこの機能があったし YouTube Music にも一応あるが、YouTube Music 側はアップロード後のメタデータ編集に難があったりと機能不足感が強い。Amazon には Amazon Music Storage という名前でこの機能があったが、既にサービスクローズしており現在は使えない (はず)。Apple Music には iCloud ミュージックライブラリという名前で今もロッカーサービスがある。YouTube Music と Apple Music 以外でロッカーサービスを提供している有名なサービスを自分は知らない。
そして Apple Music へ
自分にとってはこの3つの機能をすべて持ったサービスが必要だった。ストアサービスについてはロッカーサービスがあれば他のストアサービスで購入した音楽を預ければいいので最悪無くてもいいが、手間を考えるとあったほうがいい。これらを満たしているものが、自分の知る限り Google Play Music と Amazon Music と Apple Music の3つだけであり、現在もサービスを継続しているのが Apple Music しかない。iTunes Store 等で音楽を買って YouTube Music にアップロードするという手もあるけど、前述のように YouTube Music のロッカーサービスが微妙なので、YouTube Music を見限って Apple Music に徐々に移行しているのが今である。PC は Windows と Linux、スマートフォンは Android、スマートスピーカーは Google Nest Hub と Apple 製品とは無縁の生活をしているけど、Android 用の Apple Music アプリがまぁまぁよくできているので決断できた。Chromecast デバイスへのキャストも実装していてえらい。
Apple Music で必要な機能はほぼ満たせているものの、Google Play Music と比較しての不満点もある。一番の不満は PC の iTunes から iCloud ミュージックライブラリへのアップロードが異常に遅いこと。https://eagletmt.hateblo.jp/entry/2020/07/06/025926 のスペックを持ち 400 Mbps くらいの上り速度が出る回線があっても本当にアップロードが遅いのでサーバ側の処理が遅いんだろうけど、わけわからんくらい遅い上によく失敗してガチャガチャ操作しないと進行しなくなる。Google Play Music は Chrome 拡張必須ではあるけど Chrome からさくさくアップロードできていたのに *1。他にも iTunes のあらゆる動作が重かったり Apple Music の Web サイトが重かったりとパフォーマンスに関する細かい不満は色々あるが iCloud ミュージックライブラリへのアップロードの遅さと比べれば些細だし、アップロードの遅さがあっても他に必要な機能を提供しているサービスがないので Apple Music を選択している…… YouTube Music で唯一助かっているのは料金が YouTube Premium と抱き合わせになっていること。YouTube Premium を解約する気は今のところ無いので、YouTube Music に預けた音楽をキープしつついつか YouTube Music の機能が改善されることを祈り続けることができる。
身分証をすべて紛失したときの思い出
皆さんは財布を紛失したことはありますか? 私はあります。 紛失してからいつのまにか2年以上経過していたので、当時の思い出を書いてみる。
紛失したとき
自分の場合は電車で出掛けた先の某ゲームセンターで財布がなくなっていることに気付いた。自宅から出たときにはたしかに持っていたはずだけど、Suica やゲーセン用の小銭は別に持っていたので、自宅から最寄り駅までの間で紛失したのか、電車内で紛失したのか、降りた駅からゲーセンまでの間で紛失したのか、ゲーセン内で紛失したのか分からなかった。ゲーセン内の心当たりある場所を探しても見つからず、店員に落とし物のことを聞いても見つからず、この時点で紛失したと判断した。 紛失した財布の中にはクレジットカードが入っていたため、自分の場合はまずクレカの無効化と再発行を電話で依頼した。そして近くの交番へ行って遺失届を出した。たぶん先に遺失届を出すほうが正解だった。その場で何を紛失したか具体的に書くわけだけど、自分の場合は
- 現金
- クレジットカード
- キャッシュカード
- 運転免許証
- 保険証
を同時に紛失することになった。Suica は別に持っていたので自宅まで帰るには困らなかったし、自宅に別のクレジットカードを置いてあったので金銭面ではそこまで困難な状況にはならなかったのだが、一時的にキャッシュカードと身分証が何も無い状態になった。
再発行の手続き
現金はもう諦めるとして、紛失した各種カードを止めて再発行していく作業が必要になる。クレジットカードやキャッシュカードは各カード会社や銀行によって異なるだろうけど、自分の場合は再発行に必要なものは以下のようだった。
クレジットカード
現住所の情報をちゃんと正しい状態にしてあれば、電話一本で再発行できて1、2週間で手元に届いた。今回紛失した中で一番手軽だった。
キャッシュカード
止めるまでは電話でなんとかなったが、再発行の手続きの際に身分証が必要なパターンと、再発行して郵送してもらった後の本人確認で身分証が必要なパータンの2つを経験した。いずれにせよ身分証が必要になる。
運転免許証
運転免許センターに行けば再発行できるが、それには身分証が必要だった。
保険証
勤務先に再発行をお願いしたら1、2週間くらいで手元にきた。
すべてを再発行するまでの流れ
財布紛失により手元に身分証が一切無くなってしまったので、クレジットカード以外の再発行に苦労することになった。普段は持ち歩かないけど身分証として使いやすいものにパスポートがあるけど、自分の場合はちょうど期限が切れていて使えなかった。あとは当時自分は持っていなかったけどマイナンバーカードも持ち歩かない身分証となると思う。 当時の自分にとって、最初に再発行が可能なものは保険証のみだった。これが無かったら他にどうやって最初の身分証を得ることができたのかはよく分からない。なので保険証の再発行をお願いしつつ、キャッシュカード再発行の手続きを進めて保険証の到着を待った。 保険証が届いたら、次はそれを身分証として運転免許証を再発行した。これは運転免許センターに行けばわりと簡単。 顔写真と現住所が書かれた最強の身分証である運転免許証さえ手に入れば、キャッシュカードの再発行も進められる。ここはちょっと時間がかかったけど財布紛失からだいたい合計で1ヵ月くらいで完全に元の状態に戻った。
紛失した財布
紛失してから数か月くらいたってから、警察から連絡がきて紛失した財布が返ってきた。財布が届けられた先が出掛けた先だったので、駅からゲーセンまでの移動中かゲーセン内で紛失したのだろう。現金だけは綺麗になくなっていたが、カード類はそのまま返ってきた。まぁ返ってきたところでどれも無効化されてるわけだけど。 この経験をして以来、身分証とキャッシュカードは最低1つは自宅に置いておくよう気にしている……
ある式がある trait を実装しているかチェックするやつ
Rust は zero-cost abstractions を掲げているからか、map() や filter() のような色々な言語でよくある関数が元のコレクションの型ではなく Map や Filter のようにそれぞれ専用の型を持っているパターンがよくある。 それぞれの型が Iterator を実装しているので実装を書くときはコレクションの型のように扱えるけど、その値の具体的な型は複雑なことも多い。 async/await を使っていると Future や Stream でよくそういう状況になる。
正しく実装できていれば各 (部分) 式がどんな複雑な型を持っていても問題無いわけだけど、コンパイルエラーになってしまったときに悩むことになる。 コンパイラが「この部分式の型はこうなっていて、この trait を実装するよう要求されているけど満たしてないよ」と教えてくれるものの、その部分式がその trait を実装できていないのはもっと前のほうの実装ミスだったりする。 そういうときに「ここまででこの式/値はこういう trait を実装しているような型の値のはず」と assert を書きたくなってマクロを書いてみた。
https://github.com/eagletmt/assert_trait
std::dbg! や std::todo! といったマクロのように、開発中やデバッグ中には使われるけど最終的なコードには登場しないような使い方を想定している。 syn/quote crate めっちゃ便利……
長時間の I/O を走らせつつ定期的に短時間の I/O を走らせるやつ
長時間のタスクを走らせつつ定期的に短時間の I/O をしたいパターンがよくある。いわゆる heartbeat 的なことをやりたいときとか。 例えば長時間の外部コマンドを実行するとき、Go だと
package main import ( "fmt" "log" "os/exec" "time" ) func main() { ch := make(chan error) go func() { ch <- exec.Command("sleep", "10").Run() }() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() var err error L: for { select { case <-ticker.C: fmt.Println("Waiting...") case err = <-ch: break L } } if err != nil { log.Fatal(err) } fmt.Println("OK") }
みたいに time.Ticker と select/channel で書きやすいんだけど、Rust だと stream だと考えて futures::stream::select() を使うと良さそうだった。 https://docs.rs/futures/0.3.7/futures/stream/fn.select.html
use futures::StreamExt as _; use tokio::io::AsyncWriteExt as _; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { let interval = tokio::time::interval(tokio::time::Duration::from_secs(2)) .map(|_| futures::future::Either::Left(())); let long_running_command = futures::stream::once(tokio::process::Command::new("sleep").arg("10").status()) .map(|result| futures::future::Either::Right(result)); tokio::pin!(long_running_command); let mut stream = futures::stream::select(interval, long_running_command); let status = loop { let item = stream.next().await.unwrap(); match item { futures::future::Either::Left(_) => { // Perform some lightweight I/O tokio::io::stdout().write_all(b"Waiting...\n").await?; } futures::future::Either::Right(result) => { break result; } } }?; if !status.success() { return Err(anyhow::anyhow!("command failed")); } println!("OK"); Ok(()) }