Read OSS

CLIレイヤー:コマンド、ビュー、診断システム

中級

前提知識

  • 第1回:アーキテクチャとコードベースのナビゲーション
  • 第3回:PlanとApplyのライフサイクル(フックの理解に必要)

CLIレイヤー:コマンド、ビュー、診断システム

TerraformのCLIレイヤーは、コードベースのなかでユーザーと直接向き合う部分です。フラグの解析、出力のレンダリング、エラーの表示はすべてここで行われます。また、このレイヤーには2つの重要なアーキテクチャパターンが息づいています。レンダリングロジックとビジネスロジックをきれいに分離するビューパターンと、Goの標準的なエラー処理をソース情報付きのリッチなメッセージに置き換える診断システムです。

このレイヤーを理解することは、TerraformのUIにコントリビュートする上でも不可欠ですが、Terraformをラップしたツールや自動化スクリプトを作る際にも役立ちます。JSON出力フォーマットはビューアーキテクチャを直接反映したものだからです。

command.Meta:共有コンテキスト

第1回で触れたように、すべてのコマンドは command.Meta を埋め込んでいます。Metaが何を提供しているか、もう少し詳しく見てみましょう。commands.go#L88-L114 における構築処理は以下のとおりです。

meta := command.Meta{
    WorkingDir:            wd,
    Streams:               streams,
    View:                  views.NewView(streams).SetRunningInAutomation(inAutomation),
    Color:                 true,
    GlobalPluginDirs:      cliconfig.GlobalPluginDirs(),
    Ui:                    Ui,
    Services:              services,
    BrowserLauncher:       webbrowser.NewNativeLauncher(),
    RunningInAutomation:   inAutomation,
    ShutdownCh:            makeShutdownCh(),
    ProviderSource:        providerSrc,
    ProviderDevOverrides:  providerDevOverrides,
    UnmanagedProviders:    unmanagedProviders,
    AllowExperimentalFeatures: ExperimentsAllowed(),
}
classDiagram
    class Meta {
        +WorkingDir WorkingDir
        +Streams *terminal.Streams
        +View *views.View
        +Color bool
        +Ui cli.Ui
        +Services *disco.Disco
        +ShutdownCh chan struct
        +ProviderSource getproviders.Source
        +ProviderDevOverrides map
        +UnmanagedProviders map
        +RunningInAutomation bool
        +PrepareBackend() OperationsBackend
        +OperationRequest() *Operation
        +RunOperation() *RunningOperation
    }
    class PlanCommand {
        +Meta
        +Run(rawArgs) int
    }
    class ApplyCommand {
        +Meta
        +Destroy bool
        +Run(rawArgs) int
    }
    Meta <|-- PlanCommand
    Meta <|-- ApplyCommand

Metaは依存性注入のコンテナとして機能します。各コマンドがサービスやプロバイダーソース、ターミナルストリームを個別に構築するのではなく、すべてのコマンドが事前に設定済みの単一のMetaを共有します。これにより2つのメリットが得られます。すべてのコマンドが同じプロバイダーソースを参照できる一貫性と、モックサービスを持つMetaを構築してテストしやすいテスタビリティです。

ShutdownCh フィールドも注目に値します。これは割り込みシグナルを受け取るたびに値が送られるチャネルです。コマンドはこのチャネルをselectして、グレースフルシャットダウンを実装できます。第3回で確認したように、このシグナルは最終的にグラフウォーク内の stopHook まで伝播します。

コマンドの構造:PlanCommandを例に

internal/command/plan.go#L18-L118PlanCommand は、オペレーション系コマンドの典型的な実装例です。

func (c *PlanCommand) Run(rawArgs []string) int {
    // 1. Parse view arguments (e.g., -no-color)
    common, rawArgs := arguments.ParseView(rawArgs)
    c.View.Configure(common)

    // 2. Parse command-specific flags
    args, diags := arguments.ParsePlan(rawArgs)

    // 3. Create the command-specific view
    view := views.NewPlan(args.ViewType, c.View)

    // 4. Prepare the backend
    be, beDiags := c.PrepareBackend(args.State, args.ViewType)

    // 5. Build the operation request
    opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.Operation, args.OutPath, args.GenerateConfigPath)

    // 6. Collect variables
    opReq.Variables, varDiags = args.Vars.CollectValues(...)

    // 7. Execute
    op, err := c.RunOperation(be, opReq)

    // 8. Return exit code
    return op.Result.ExitStatus()
}
sequenceDiagram
    participant User
    participant Run as PlanCommand.Run()
    participant Args as arguments.ParsePlan
    participant View as views.NewPlan
    participant BE as PrepareBackend
    participant Op as RunOperation

    User->>Run: terraform plan -out=plan.tfplan
    Run->>Args: ParsePlan(rawArgs)
    Args-->>Run: typed args struct
    Run->>View: NewPlan(viewType, baseView)
    View-->>Run: PlanView
    Run->>BE: PrepareBackend(stateArgs)
    BE-->>Run: OperationsBackend
    Run->>Op: RunOperation(backend, opReq)
    Op-->>Run: RunningOperation
    Run-->>User: exit code

この7ステップのパターンは planapplyrefreshimport に共通しています。argumentsパッケージは、生の文字列操作ではなく、コマンドごとに型安全なフラグ解析を提供します。また、ビューは早い段階で生成されるため、フラグの解析エラーさえも正しいフォーマット(人間向けまたはJSON)でレンダリングできます。

Tip: plan-detailed-exitcode フラグを使うと、適用すべき変更がある場合に終了コード2が返されます(113〜114行目)。出力をパースせずにplanの結果を検出したいCI/CDパイプラインで非常に重宝します。

ビューレイヤー:人間向けとJSON出力の切り替え

internal/command/views/view.go#L17-L35 のビューシステムは、Terraformのアーキテクチャのなかでも特に洗練された設計の一つです。

type View struct {
    streams             *terminal.Streams
    colorize            *colorstring.Colorize
    compactWarnings     bool
    runningInAutomation bool
    configSources       func() map[string][]byte
}

各コマンドは独自のビューインターフェースを持ちます。たとえばplanビューは、Diagnostics()Operation()(プランサマリーの表示用)、HelpPrompt() といったメソッドをサポートしています。このインターフェースの背後には2つの実装が存在します。

  1. Humanビュー — カラー出力とテキスト折り返し、ASCIIアートのステータス表示を持つ人間向けの出力
  2. JSONビュー — 機械処理向けに、1行1イベントの構造化JSONを出力
classDiagram
    class View {
        +streams *terminal.Streams
        +colorize *colorstring.Colorize
        +Diagnostics(diags)
    }
    class PlanHuman {
        +view *View
        +Diagnostics(diags)
        +Operation(plan)
        +HelpPrompt()
    }
    class PlanJSON {
        +view *JSONView
        +Diagnostics(diags)
        +Operation(plan)
    }
    View <-- PlanHuman : embeds
    View <-- PlanJSON : uses JSONView

-json フラグによって実装が切り替わります。この設計のおかげで、コマンドの Run() メソッド内のビジネスロジックは出力フォーマットを一切意識する必要がありません。view.Diagnostics(diags) のようなビューメソッドを呼び出すだけで、実際のレンダリング方法はビューが決定します。TerraformのJSON出力が信頼でき、かつ完全な情報を持つのは、人間向け出力と同じコードパスを通るからこそです。

フックによる進捗レポート

第3回で説明したように、フックはグラフウォーク中のオブザーバーメカニズムです。CLIレイヤーはこれらのコールバックをユーザーに見える出力へ変換する2つのフック実装を提供しています。

UiHookinternal/command/views/hook_ui.go)は、おなじみの人間向け進捗表示をレンダリングします。

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 45s [id=i-1234567890]

JSONHookinternal/command/views/hook_json.go)は構造化されたイベントを出力します。

{"@level":"info","@message":"aws_instance.web: Creating...","type":"apply_start","hook":{"resource":{"addr":"aws_instance.web"},"action":"create"}}
sequenceDiagram
    participant Node as ResourceNode
    participant ECtx as EvalContext
    participant UiHook as UiHook / JSONHook
    participant Output as Terminal / JSON Stream

    Node->>ECtx: Hook(PreApply)
    ECtx->>UiHook: PreApply(id, action, prior, planned)
    UiHook->>Output: "aws_instance.web: Creating..."
    Note over UiHook: Start timer goroutine
    UiHook-->>ECtx: HookActionContinue
    Node->>Node: provider.ApplyResourceChange()
    Node->>ECtx: Hook(PostApply)
    ECtx->>UiHook: PostApply(id, newState, err)
    UiHook->>Output: "Creation complete after 45s"

UiHookは PreApply でタイマーゴルーチンを起動し、一定間隔で「Still creating...」メッセージを出力します。PostApply コールバックでタイマーを停止し、最終ステータスを表示します。フックはノードが何をしているかを知る必要がなく、状態の遷移を観察するだけという点がこの設計のポイントです。

診断システム:tfdiags

Terraformのコードベースで最も広く使われているアーキテクチャパターンが、診断システムです。Goの標準的な error 型を返す代わりに、Terraformのほぼすべての関数は tfdiags.Diagnostics を返します。これはエラーと警告の両方を保持できる Diagnostic 値のスライスです。

internal/tfdiags/diagnostic.go#L12-L28Diagnostic インターフェース:

type Diagnostic interface {
    Severity() Severity
    Description() Description
    Source() Source
    FromExpr() *FromExpr
    ExtraInfo() interface{}
}

internal/tfdiags/diagnostics.go#L24Diagnostics スライス型:

type Diagnostics []Diagnostic

49〜60行目Append() メソッドは多態的な設計になっており、DiagnosticDiagnosticserrorhcl.Diagnosticsmultierror.Error のいずれも受け付け、すべてを同じ表現に正規化します。どのレイヤーのコードも、あらゆるエラーライクな値をそのままappendできます。

var diags tfdiags.Diagnostics
result, err := doSomething()
diags = diags.Append(err)  // works with any error type
flowchart TD
    HCL["HCL Parser"] -->|"hcl.Diagnostics"| Diags["tfdiags.Diagnostics"]
    Config["Config Loader"] -->|"tfdiags"| Diags
    Core["terraform.Context"] -->|"tfdiags"| Diags
    Provider["Provider gRPC"] -->|"errors → tfdiags"| Diags
    Diags --> View["Views Layer"]
    View --> Human["Human Output<br/>(with source snippets)"]
    View --> JSON["JSON Output<br/>(structured)"]

なぜ error より優れているのでしょうか。理由は3つあります。

  1. 警告とエラーを同時に扱える — 関数は実行を止めることなく警告を返せます。動作を段階的に変えていくTerraformの非推奨化ワークフローにおいて、これは不可欠な特性です。

  2. ソース情報の付与 — 診断情報はファイル名・行番号・列番号を持つ Source を保持します。レンダリング時には、問題のある設定行を正確に示すソースコードスニペット付きの出力が生成されます。

  3. 式のコンテキストFromExpr() は、診断を発生させたHCL式と評価コンテキストをキャプチャします。これにより、どの変数の値が問題を引き起こしたかを示すリッチなエラーメッセージが実現します。

コードベース全体で使われる慣用パターンは次のとおりです。

var diags tfdiags.Diagnostics
// ... do work, appending to diags ...
if diags.HasErrors() {
    return nil, diags
}
return result, diags  // may still contain warnings

このパターンは数百か所に登場します。従来の if err != nil { return err } に代わり、コールスタックのどの層でもすべての診断情報を失わずに伝達できます。

ターミナル検出とストリーム

terminal パッケージはTTY検出とターミナル幅の計測を担当します。Streams 構造体は stdinstdoutstderr をそれぞれがターミナルかどうか、および列幅のメタデータとともにラップしています。この情報はMetaを通じてビューに伝達され、ビューは以下の判断に使用します。

  • カラー出力を有効にするかどうか
  • テキストをどの幅で折り返すか
  • インタラクティブなプロンプトを表示するかどうか
  • ターミナル制御コードが必要な進捗スピナーを表示するかどうか

環境変数 TF_IN_AUTOMATION を設定すると、Viewの RunningInAutomation が有効になり、人間がコマンドをインタラクティブに操作することを前提としたメッセージが抑制されます。

次回予告

ここまでで、CLIからコアエンジン、プロバイダープラグインに至るすべてのレイヤーを探索しました。シリーズ最終回では、設定ロードシステムを掘り下げます。.tf ファイルがHCLを通してどのように解析され、モジュールツリーへと組み立てられ、グラフウォーク中に遅延評価されてすべての処理を駆動する cty.Value の結果を生み出すのか、その全体像を明らかにします。