injecuet: CUEに環境変数を注入する便利CLIツールを書いた

CUEとは

CUEJSONYAMLのスーパーセットのような構文を持ちながら、データ・スキーマ検証などが行える言語のこと。 KubernetesYAML生成にも使われているそう (そのシーンで使ったことはない)。

軸となるコンセプトは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 exportJSONYAMLを出力できるコマンド。引数に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の構築に役立てる便利なツールができた。

どうぞご利用ください。