CUEとは
CUEはJSONやYAMLのスーパーセットのような構文を持ちながら、データ・スキーマ検証などが行える言語のこと。 KubernetesのYAML生成にも使われているそう (そのシーンで使ったことはない)。
軸となるコンセプトはTypes are values
と表される。言い換えると型と値は束を成す。
束は半順序集合で、CUEでは型のとりうる範囲を表現している。値は要素がただひとつそれ自身の束とされる。ちなみにCUEにおいて値を得ることを解決 (resolve) と呼んでいる。
ここらへんの話はすべてThe Logic of CUE | CUEからの引用・言い換えなので詳しくはそちらをあたるのがよい。
The Logic of CUEにある下記の図がわかりやすい。
もうちょっと実用上の特徴について触れると、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の使い方
さて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の構築に役立てる便利なツールができた。