CUEとは
CUE はJSON やYAML のスーパーセットのような構文を持ちながら、データ・スキーマ 検証などが行える言語のこと。
Kubernetes のYAML 生成にも使われているそう (そのシーンで使ったことはない)。
軸となるコンセプトはTypes are values と表される。言い換えると型と値は束 を成す。
束は半順序集合で、CUEでは型のとりうる範囲を表現している。値は要素がただひとつそれ自身の束とされる。ちなみにCUEにおいて値を得ることを解決 (resolve) と呼んでいる。
ここらへんの話はすべてThe Logic of CUE | CUE からの引用・言い換えなので詳しくはそちらをあたるのがよい。
The Logic of CUEにある下記の図がわかりやすい。
{x, y}
{x}
{y}
{}
もうちょっと実用上の特徴について触れると、CUEにおいて制約も型表現のひとつとなる。たとえば >0
は「ゼロより大きい数値」という型を表す。
さらにこれら制約 (= 型) は合成可能で、 >0 & <10
だと「0より大きく10未満の数値」になる。
そして値は「ちょうどその値だけを表す型」とみなされる。言い換えると要素数 が1の集合が値となる。
injecuetを作るきっかけ
ecspresso で使うサービス定義・タスク定義中にIAMロールのARNなどをtfstateプラグイン で埋めていたが、使い勝手が悪く・拡張性にも乏しいので別の方法でサービス定義やタスク定義を生成したいと考えた。
というのもtfstateプラグイン はTerraformが出力するtfstateの情報すべてにアクセスできてしまい、Terraformモジュールのカプセル化 を破壊するおそれがあることがひとつ。
Terraformのリファクタリング をしている中でモジュールへ分割したところtfstate内の識別子が変わったことによりecspressoからの参照が壊れたことが直接のきっかけだった。
加えてコレクションの扱いが弱いことも挙げられる。
ecspressoは内部でtext/template を使用している。range
構文が使えるが、validなJSON で配列を出力しようとすると strings.Join
を使うか「ループの最後だったらカンマを出力しない」という記述が必要になる。
text/template内で strings.Join
のような関数を使うにせよ、ループの最後かを検出するにせよ、テンプレート処理する時にカスタム関数の定義を渡す必要がある。
そしてそのカスタム関数の定義はecspressoの実行時に渡せるようにはなっていない。
tfstateプラグイン はあくまでecspresso内に閉じた機構により動的に有効になるだけであって、外部から機能を追加できるような純然たるプラグイン 機構は持っていない。
ecspressoにそのような機能を追加することも考えられたがいささかオーバーキルだし、JSON を良いかんじに出力するという点に関しては別のツールに任せるのが良いように思えた。
そこで先に紹介したCUEを利用することを思い付いた。とはいえCUE自体では実用上足りない機能があった。injecuetはその欠けていた機能を足すことを目指している。
injecuetの使い方
github.com
さてinjecuetについて。injecuetはCUE文書を受け取り、 @injectenv
という属性がついたフィールドに対応する環境変数 の値を注入した新しいCUE文書を出力する。
文章だけだとわかりづらいのでsynopsisを引用する。
cat src.cue
# name: string @injectvar(USER_NAME)
env USER_NAME=aereal injecuet ./src.cue
# name: "aereal" @injectvar(USER_NAME)
より実践的な使い方はこう:
cat complex.cue
# import "strconv"
#
# #varAge: string @injectvar(AGE)
# age: strconv.Atoi(#varAge)
env AGE=17 injecuet -output ./out.gen.cue ./src.cue
cat out.gen.cue
# #varAge: "123"
# age: 123
cue export ./src.cue ./out.gen.cue
# {
# "age": 123
# }
cue export
はJSON やYAML を出力できるコマンド。引数にCUE文書を渡すことができ、複数渡すこともできる。
複数渡すとすべての制約を満たすよう合成される。
cue export
は合成されたCUE文書が解決されている必要がある。言い換えるとすべて値になっていなければいけない。
age: >=0
のような型は要素数 が2以上の集合なので値ではない。これが age: 17
だと値なのでOK.
injecuetの実践的な使い方
タスク定義中の taskRoleArn
を外部から渡すことを考えてみる。
refs. Task definition parameters - Amazon Elastic Container Service
まずCUEによる記述:
family: "my-app"
taskRoleArn: string
これをexportしてみる:
[2021-08-17 22:31:31] ✘╹◡╹✘ < cue export testdata/task-definition.cue
taskRoleArn: incomplete value string
想定通り怒られる。
injecuetを使って環境変数 を注入し、タスク定義のJSON を出力したい。
まず属性を付与する。
family: "my-app"
taskRoleArn: string @injectenv(ECS_TASK_ROLE_ARN)
injecuet実行時に環境変数 として渡す:
[2021-08-17 22:37:14] ✘╹◡╹✘ < ECS_TASK_ROLE_ARN='aws:arn:iam:123456789012:role/task-role' injecuet ./task-definition.cue > resolved.task-definition.cue
[2021-08-17 22:37:26] ✘╹◡╹✘ < cat resolved.task-definition.cue
{
family: "my-app"
taskRoleArn: "aws:arn:iam:123456789012:role/task-role" @injectenv(ECS_TASK_ROLE_ARN)
}
期待通り taskRoleArn
に値が注入されている。
最後にexportする。
[2021-08-17 22:38:24] ✘╹◡╹✘ < cue export ./resolved.task-definition.cue | jq .
{
"family": "my-app",
"taskRoleArn": "aws:arn:iam:123456789012:role/task-role"
}
完成。ちゃんとvalidなJSON が出力されている。
さらにCUEのTypes are valuesという特性について思い出してほしい。以下の例を見るとCUEの強力さが伺えると思う。
[2021-08-17 22:41:14] ✘╹◡╹✘ < cat task-definition.cue
family: "my-app"
taskRoleArn: =~"^aws:arn:iam:.+" @injectenv(ECS_TASK_ROLE_ARN)
// ↑stringという定義から変わっていることに注目
[2021-08-17 22:41:56] ✘╹◡╹✘ < ECS_TASK_ROLE_ARN='aws:arn:ecs:...' \ # 間違ってIAMロールではない別のサービスのリソースのARNを渡してしまった
injecuet ./task-definition.cue > resolved.task-definition.cue
[2021-08-17 22:42:03] ✘╹◡╹✘ < cat resolved.task-definition.cue
{
family: "my-app"
taskRoleArn: =~"^aws:arn:iam:.+" @injectenv(ECS_TASK_ROLE_ARN)
// ↑環境変数が展開されていないことに注目
}
[2021-08-17 22:45:32] ✘╹◡╹✘ < cue export ./resolved.task-definition.cue # 当然exportは失敗する
taskRoleArn: incomplete value =~"^aws:arn:iam:.+":
./resolved.task-definition.cue:3:15
CUEは正規表現を型に指定できる 。
=~"^aws:arn:iam:.+"
は「^aws:arn:iam:
で始まり任意の文字が1つ以上続く文字列」という型を表す。
aws:arn:ecs:...
という文字列はマッチしないので解決されず、JSON へのexportもできない。
環境変数 の注入はありふれたアイデア だが、JSON は数値や配列など豊かな型を持っており、文字列として表現される環境変数 からそれらへの変換は自明ではない。
制約を表現できるならそれに越したことはない。
式を書いて文字列を他の型へ変換する
さらにCUEは簡単な式を書くことができる。
import "strconv"
_varPort: =~"^[0-9]+$" @injectenv(PORT)
port: strconv.Atoi(_varPort)
これをexportしてみる:
[2021-08-17 23:24:54] ✘╹◡╹✘ < PORT=8080 injecuet ./port.cue > resolved.port.cue
[2021-08-17 23:25:04] ✘╹◡╹✘ < cue export ./resolved.port.cue
{
"port": 8080
}
PORTに制約を満たさない値を渡した場合:
[2021-08-17 23:25:41] ✘╹◡╹✘ < PORT=abc injecuet ./port.cue > invalid.port.cue
[2021-08-17 23:25:48] ✘╹◡╹✘ < cat invalid.port.cue
import "strconv"
_varPort: =~"^[0-9]+$" @injectenv(PORT)
port: strconv.Atoi(_varPort)
[2021-08-17 23:25:50] ✘╹◡╹✘ < cue export ./invalid.port.cue
error in call to strconv.Atoi: non-concrete value string:
./invalid.port.cue:4:11
./invalid.port.cue:3:11
便利。pkg · pkg.go.dev に標準で利用できるパッケージがあるので、たとえば strings.Split
を使ってカンマ区切りの文字列をリストに展開できる。
ちなみに _
始まりのフィールドはHidden fields を呼ばれ、export時には公開されない。
なのでこういう風に、環境変数 を束縛するが最終的な出力に含めたくない一時変数として利用することができる。
おわりに
シンプルながらCUE自体の強力さもあって複雑なJSON の構築に役立てる便利なツールができた。
どうぞご利用ください。