dagger.io Getting Started
What is Dagger?
Dagger は、CI/CD のためのポータブルデベロップメントキットです。
Dagger を使用することで、ソフトウェアチームは最小限の労力で強力な CI/CD パイプラインを開発し、それをどこでも実行できるようになります。
Dagger のメリット
- パイプラインの迅速なデバッグ。 DaggerはローカルマシンでもCIでも同じように動作します
どのような仕組みですか?
- Google で開発された革新的な宣言型言語 CUE ですべてを結びつけましょう。YAML 地獄に悩む必要はありません
- ローカルマシンで即座にテストとデバッグができます。ローカルマシンのデバッグが困難だった過去から開放されます
- Docker 互換のランタイムでパイプラインを実行し、移植性を最大化します。つまり、ほとんどの最新の CI ランナーで Dagger をすぐに実行できます。
ローカル開発環境でのCI/CD
brew install dagger/tap/dagger
# macOS ARM:
% type dagger
dagger is /opt/homebrew/bin/dagger
% dagger version
dagger 0.2.8 (92c8c7a2) darwin/arm64
サンプルアプリのビルドテスト
% git clone https://github.com/dagger/dagger
% cd dagger
% git checkout v0.2.8
% cd pkg/universe.dagger.io/examples/todoapp
% dagger do build
[✔] actions.build.run.script 0.0s
[✔] actions.deps 0.0s
[✔] client.filesystem."./".read 0.2s
[✔] actions.test.script 0.0s
[✔] actions.test 0.7s
[✔] actions.build.run 4.8s
[✔] actions.build.contents 0.0s
[✔] client.filesystem."./_build".write 0.1s
ビルドされたアプリケーションを開きます
% open _build/index.html
参考メモ (IDE のプラグイン)
- JetBrains (IntelliJ IDEA)
- Visual Studio Code
Dagger Actions
Discovery
Available Actions の箇所を見ることで実行可能なものを見つけることが出来る
% dagger do --help
.....
Available Actions:
deps
test
build
deploy
Execution
dagger do でそれを実行することが出来ます
% dagger do build
[✔] actions.test.script 0.0s
[✔] actions.deps 0.0s
[✔] client.filesystem."./".read 0.0s
[✔] actions.test 0.0s
すべては計画から始まる
Dagger で宣言されたコンフィグは、プランから始まります。 dagger.#Plan
これは、Getting Started todoapp のプラン構成です
// ...
// A plan has pre-requisites that we cover below.
// For now we focus on the dagger.#Plan structure.
// ...
dagger.#Plan & {
client: {
filesystem: {
// ...
}
env: {
// ...
}
}
actions: {
deps: docker.#Build & {
// ...
}
test: bash.#Run & {
// ...
}
build: {
run: bash.#Run & {
// ...
}
contents: core.#Subdir & {
// ...
}
}
deploy: netlify.#Deploy & {
// ...
}
}
}
dagger do build
を実行すると、次の出力が生成されます
[✔] client.filesystem.".".read 0.0s
[✔] actions.deps 1.1s
[✔] actions.test.script 0.0s
[✔] actions.test 0.0s
[✔] actions.build.run.script 0.0s
[✔] actions.build.run 0.0s
[✔] actions.build.contents 0.0s
[✔] client.filesystem."./_build".write 0.1s
これらのアクションは以前に実行されたことがあるため、キャッシュされ、2秒以内に完了します。
クライアントとのやり取り
以下のことが実行可能です
- ファイルやディレクトリの読み書きができる
- ローカルソケットの使用
- 環境変数の読み込み
- コマンドの実行
- 現在のプラットフォーム情報を取得する
ファイルシステムへのアクセス
ローカルディレクトリを読み込みたい場合
dagger.#Plan & {
// パスは、絶対パスでも、現在の作業ディレクトリからの相対パスでもかまいません
client: filesystem: ".": read: {
// CUE type は期待される内容を定義します
contents: dagger.#FS
exclude: ["node_modules"]
}
actions: {
copy: docker.#Copy & {
contents: client.filesystem.".".read.contents
}
// ...
}
}
ローカルにファイルを書き込むことも簡単です
import (
"encoding/yaml"
// ...
)
dagger.#Plan & {
client: filesystem: "config.yaml": write: {
// CUE 値を YAML フォーマットの文字列に変換する
contents: yaml.Marshal(actions.pull.output.config)
}
}
Environment variables (環境変数)
環境変数は、ローカルマシンから文字列または secret として読み込むことができ、単にタイプを指定するだけです。
package main
import (
"dagger.io/dagger"
"universe.dagger.io/docker"
)
dagger.#Plan & {
client: env: {
// load as a string
REGISTRY_USER: string
// load as a secret
REGISTRY_TOKEN: dagger.#Secret
// load as a string, using a default if not defined
BASE_IMAGE: string | *"registry.example.com/image"
}
actions: pull: docker.#Pull & {
source: client.env.BASE_IMAGE
auth: {
username: client.env.REGISTRY_USER
secret: client.env.REGISTRY_TOKEN
}
}
}
Running commands (コマンドの実行)
dagger.#Plan & {
client: commands: {
os: {
name: "uname"
args: ["-s"]
}
arch: {
name: "uname"
args: ["-m"]
}
}
actions: build: go.#Build & {
os: client.commands.os.stdout
arch: client.commands.arch.stdout
// ...
}
}
Platform (プラットフォーム情報)
プラットフォーム情報が必要な場合は、前の例のように uname
を実行するよりも、もっとポータブルな方法があります
dagger.#Plan & {
client: _
actions: build: go.#Build & {
os: client.platform.os
arch: client.platform.arch
// ...
}
}
How to use secrets
具体的には、次のことができます
- ファイルにシークレットを書き込む
- ファイルからシークレットを読み出す
- 環境変数からシークレットを読み取る
- コマンドの出力からシークレットを読み取る
- コマンドの入力としてシークレットを使用する
Environment
最も単純な使用例は、環境変数から読み取る場合です。
dagger.#Plan & {
client: env: GITHUB_TOKEN: dagger.#Secret
}
What is CUE?
CUE (CUE 言語 | Cuelang) は、Google ですべてのアプリケーションのデプロイに使用されている Borg Configuration Language (BCL) を共同開発した Marcel van Lohuizen 氏によって作られた強力な設定言語です。
CUE は、Google で長年にわたって設定言語を書いてきた経験の成果であり、いくつかの厄介な落とし穴を避けながら、開発者の体験を向上させることを目的としています。
JSON のスーパーセットであり、宣言的なデータ駆動型プログラミングを通常の命令型プログラミングと同様に快適で生産的にするための機能を追加しています。
コンフィギュレーション言語の必要性
何十年もの間、開発者、エンジニア、システム管理者は、 INI
、ENV
、YAML
、XML
、JSON
(および Apache、Nginx などのカスタムフォーマット)を組み合わせて、設定、リソース、オペレーション、変数、パラメータ、ステートなどを記述してきました。
これらの例はデータの保存には適していますが、単なるデータ・フォーマットであって言語ではありませんし、ロジックを実行したりデータを直接操作する機能もありません。
if文、forループ、内包、文字列補間などのシンプルかつ強力な機能は、これらのフォーマットでは、実行のための別プロセスを使用しない限り不可能です。
その結果、変数やパラメータを注入し、テンプレート言語(Jinjaなど)やDSL(ドメイン特化言語)で指示された別のエンジンでロジックを実行する必要があります。
テンプレート言語とDSLはしばしば併用され、技術的にはうまくいきますが、その結果、コードベースや単一のファイルでさえ、過度に冗長で、テンプレート言語とさまざまなDSL(時には複数のDSLが、一方の出力を他方の入力にフィードする!)が散在し、スキーマを強制せずに(さらに努力しないと)堅い構造を作り、それによってコードが論理的に難しく、保守が困難で、最悪の場合、副作用が起こりやすくなってしまうという結果に終わります。
CUE のような設定言語では、データを指定するだけでなく、目的の出力を得るために必要なあらゆるロジックでそのデータに対応することができます。
さらに、おそらく最も重要なことですが、CUE では、データを具体的な値として指定するだけでなく、その具体的な値の型や、たとえば最小値や最大値などの制約も指定することが可能です。
スキーマを定義することもできますが、JSON Schema とは異なり、CUE はスキーマの定義とその適用の両方を行うことができます。
コードとしての設定の問題点については、「設定の複雑さの呪い」と「Cue の勝利の方法」をご覧ください。
- https://blog.cedriccharly.com/post/20191109-the-configuration-complexity-curse/
- https://blog.cedriccharly.com/post/20210523-how-cue-wins/
CUE を理解する
CUE ドキュメントを書き直したり、優れた CUE チュートリアルを再現したりするのではなく、dagger チュートリアルを進めるために必要な CUE に関する十分な理解を得ることができます。
少なくともここでは、いくつかの重要なコンセプトを理解する必要があります。
- Cue は JSON のスーパーセット(上位互換)である
- 型は値
- 具体的な値
- 制約、定義、スキーマ
- 統一性
- デフォルト値、継承の性質
- エンベッディング
- パッケージ
CUE をインストールすると最も便利ですが、ご希望であれば、CUE プレイグラウンドでこれらの例を試すこともできます。
Cue is a Superset of JSON
JSON で表現できるものは CUE でも表現できますが、CUE のすべてが JSON で表現できるわけではありません。CUE は、特定の文字を完全に排除できる JSON の「ライト」バージョンもサポートしています。次のコードを見てください。
{
"Bob": {
"Name": "Bob Smith",
"Age": 42
}
}
Bob: Name: "Bob Smith"
この例では、CUE でトップレベルのキー Bob を 2 回宣言しています。
1 回は括弧、引用符、カンマを使ったより冗長な JSON 形式で、もう 1 回は余計な文字を使わない「lite」スタイルで宣言していることがわかります。
CUE はショートハンドをサポートしていることにも注目してください。オブジェクト内の 1 つのキーを対象とする場合、中括弧は必要なく、コロンで区切られたパスとして記述することができます。
CUE プレイグラウンドで試してみて、出力に注目してください(異なるフォーマットを選択できます)。トップレベルの Bob キーは2回宣言されていますが、CUE が自動的に2つの宣言を統一しているため、出力は1回だけです。
同じフィールドを複数回宣言しても、同じ値を提供すれば問題ありません。後述の「デフォルト値および継承の性質」を参照してください。
Types are Values
前の例では、Name の値を文字列リテラル "Bob Smith" として、Age の値を整数リテラル 42 として定義しましたが、これらは両方とも具象値です。
一般に、CUE の出力は API や dagger などの CLI ツール、CI/CD プロセスなど、他のシステムの入力として使用されます。これらのシステムは、データがスキーマに準拠し、各フィールドが型を持ち、min や max、enum、正規表現などの関数によって制約を受ける可能性があると想定しているようです。
このことを念頭に置いて、例えば Name の値を整数にしたり、Age の値を文字列にしたりしないように、型と制約を強制する必要がある。
Bob: {
Name: string // type as the value
Age: int
}
Bob: {
Name: "Bob Smith" // literals match the type
Age: 42
}
ここでは、Name フィールドを文字列で、Age フィールドを int で定義しています。string と int が引用符で囲まれていないことに注目してください。
これは、「型は値である」と言ったときの意味です。これは、Go などの型付けの強い言語を書いたことがある人なら、よくご存知のことでしょう。これらの型が定義されると、CUE はそれを強制的に適用し、Name に整数、Age に文字列を指定しようとするとエラーになります。
この例の出力は、暗黙的な単一化の結果であることに注意する必要があります。CUE プレイグラウンドで試してみてください。
Concrete Values
CUE は最終的にデータをエクスポートするために使用され、そのデータが適切に定義されたスキーマに対して検証された場合に最も有用です。
CUE が何かをエクスポートするためには、オプションとしてマークされていないすべての定義済みフィールドに具体的な値を提供する必要があります。必須フィールドを単に型として定義し、具体的な値を指定しないままにしておくと、CUE はエラーを返します。
Bob: {
Name: string
Age: int
}
Bob: {
Name: "Bob Smith"
//Age: is considered "incomplete" because no concrete value is defined
}
Definitions
実際のシナリオでは、複数の人物を定義し、それぞれがスキーマを満たしていることを確認する必要がありそうだ。そこで便利なのが定義である。
#Person: {
Name: string
Email: string
Age?: int
}
Bob: #Person & {
Name: "Bob Smith"
Email: "bob@smith.com"
// Age is now optional
}
この例では、#Person は #記号で示されるように、定義であると宣言しています。
こうすることで、Person オブジェクトを特定のフィールドセット、特定の型に制限しています。
定義はデフォルトでクローズドなので、#Person に定義で指定されていないフィールドを含めることはできません。また、Age には「?」が含まれていますが、これはこのフィールドがオプション (nullable) であることを表しています。
定義そのものは最終的な出力にエクスポートされない。具体的な出力を得るために、Bob というフィールドを #Person と宣言し、シングル&(&&による論理ANDとは違う!)を使って、#Person の定義を、その定義で定義された制約を満たす具体値を持つオブジェクトと統一しました。
Unification
統一は、CUE を CUE たらしめている核心部分です。値が燃料であるならば、統一はエンジンです。統一することで、制約の定義と具体的な値の計算の両方が可能になるのです。この考え方は、いくつかの例で見ることができます。
import (
"strings" // import builtin package
) // パッケージについては後述します
#Person: {
// さらに、最小と最大の長さに制約を加える
Name: string & strings.MinRunes(3) & strings.MaxRunes(22)
// 正規表現も利用出来ます
Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// 現実的な年齢に制約します
Age?: int & >0 & <140
}
Bob: #Person & {
Name: "Bob Smith"
Email: "bob@smith.com"
Age: 42
}
// output in YAML:
//Bob:
// Name: Bob Smith
// Email: bob@smith.com
// Age: 42
ここでの出力は、#Person の定義と具象値を含むオブジェクトを統一したもので、それぞれの具象値は、定義内のフィールドで宣言された型と制約で統一された積です。
Default Values and the Nature of Inheritance
オブジェクト(構造体)を統一する場合、フィールドを再帰的に統一するマージが行われますが、JavaScript の JSON オブジェクトのマージなどとは異なり、異なる値は上書きされずにエラーになります。
これは、CUE の可換性のためでもありますが(順序が重要でないなら、どうやって他の値より1つの値を選ぶのでしょうか)、オーバーライドがあまりにも簡単に不要な、デバッグが難しい副作用をもたらすという事実が主な原因です。別の例を見てみましょう。
import (
"strings" // a builtin package
)
#Person: {
// さらに、最小と最大の長さに制約を加える
Name: string & strings.MinRunes(3) & strings.MaxRunes(22)
// 正規表現も利用出来ます
Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// 現実的な年齢に制約します
Age?: int & >0 & <140
// Job is optional and a string
Job?: string
}
#Engineer: #Person & {
Job: "Engineer" // ジョブはさらに、必要な、まさにこの値に制約されます。
}
Bob: #Engineer & {
Name: "Bob Smith"
Email: "bob@smith.com"
Age: 42
// Job: "Carpenter" // エラーが発生します
}
// output in YAML:
//Bob:
// Name: Bob Smith
// Email: bob@smith.com
// Age: 42
// Job: Engineer
Bob オブジェクトが #Engineer から Job 値を継承し、それが #Person から制約を継承することは可能ですが、Job 値をオーバーライドすることはできません。
もし、Bob オブジェクトに別の仕事をさせたい場合は、別のタイプに統一するか、#Engineer:Job: フィールドにデフォルト値でより緩い制約を加える必要があります。Job フィールドを次のように変更してみてください。
#Engineer: #Person & {
Job: string | *"Engineer" // 任意の文字列を指定することができます。しかし、具体的な値が明示的に定義されていない場合、*デフォルト*は "Engineer" になります
}
Bob はデフォルト値を引き継ぎますが、別のジョブを指定することができるようになりました。
Embedding
CUE は、Golang Embedding やオブジェクト指向のコンポジションと同様に、ある定義を別の定義に埋め込むことができます。
これにより、定義に余分な深さを加えることを避けることができる
import (
"strings" // a builtin package
)
#Person: {
// さらに、最小と最大の長さに制約を加える
Name: string & strings.MinRunes(3) & strings.MaxRunes(22)
// 正規表現も利用出来ます
Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// 現実的な年齢に制約します
Age?: int & >0 & <140
// Job is optional and a string
Job?: string
}
// Engineer の定義に #Person を組み込み、Domain フィールドを追加する。
#Engineer: {
Domain: "Backend" | "Frontend" | "DevOps"
// ジョブに関する制約を埋め込み
#Person & {
Job: "Engineer" // ジョブはさらに、必要な、まさにこの値に制約されます。
}
}
Bob: #Engineer & {
Name: "Bob Smith"
Email: "bob@smith.com"
Age: 42
Domain: "Backend"
}
// output in YAML:
//Bob:
// Name: Bob Smith
// Email: bob@smith.com
// Age: 42
// Job: Engineer
// Domain: Backend
Engineer を埋め込んだ定義は、そのプロパティを共有し、定義から直接アクセスできるようになります。
Packages
CUE のパッケージは、モジュール化された再利用可能なコードを書くことを可能にします。スキーマを定義して、それを様々なファイルやプロジェクトにインポートすることができます。
もしあなたが Go を書いたことがあるなら、CUE はとても身近に感じられるはずです。Go で書かれているだけでなく、その動作や構文の多くも Go をモデルにしています。
CUE には、strings、regexp、math など、多くの組み込みパッケージがあります。これらの組み込みパッケージは、他に何かをダウンロードしたりインストールしたりする必要がなく、CUE ですでに利用可能です。
サードパーティ製パッケージとは、cue.mod/pkg/ フォルダ内に配置され、universe.dagger.io のような完全修飾ドメインで始まるパッケージのことを指します。
最後の数例では、組み込みの文字列パッケージを読み込むために import ステートメントを含んでいます。チュートリアルや他の例では、universe.dagger.io の他のパッケージがインポートされて使用されることに気がつくでしょう。