Screaming Loud

日々是精進

RustでEC2検索を簡単にするCLIを作った ~ ec2-search ~

本記事は Rust Advent Calenderの9日目の記事です。

今回はRustでCLIを作ったので、機能紹介と作る上でのポイントなどを紹介します。

f:id:yuutookun:20201208202826p:plain

書こうと思っていたネタが、7日目の 2020 年版 Command Line Tool を作ってみる in Rust - Qiita と結構被ってしまったのですが、 どうやってコードを書くかというより、自分がハマったどうやってデプロイするか周りをメインに紹介します。

なぜ作ろうと思ったか

今回このCLIを作ったモチベーションとしてはRustでなにか作りたいという気持ちがメインではありますが、

  • kubectlでget nodeしたとき、どういうインスタンスかわからない
  • EC2にssm-agentで入りたいときにインスタンスIDを調べるのが大変
  • port-forwardしたいときにIPわからん
  • aws cliはシュッと使うにはわかりづらい

という気持ちがあったのでEC2の検索CLIを作ろうと思いました。

実際、自分で作ってから便利に使えてるのでよかったと思いました。

ec2-searchというCLIを作りました。

github.com

実際のコマンドはec2sです。 簡単にEC2の検索ができるようになるべく使い心地的にはkubectlのような感じにしたく、設計しました。 当初はインスタンスだけの検索にしようかと思ったのですが、TargetGroupなどEC2関連の検索全般も欲しくなったのでカバー範囲としてはざっくりEC2周りです。

機能一覧

現状ではそこまで機能は多くないのですが、 EC2インスタンスの検索は info というコマンドで検索できます。

$ ec2s instance info -q api
ID           Name       Status   Type
i-012345678  test-api1  running  t2.micro
i-023456789  test-api2  running  t3.small
counts: 2

instanceのエイリアスとして ec2s i info -q api とも検索できます この -q はAWSコンソールの検索ボックスと同じようにNameやID、Private DNS Nameで引けるようになっています。

他にもdnsやipを出力させるコマンドがあります

$ ec2s i help
ec2s-instance 0.7.0
search instance

USAGE:
    ec2s instance <SUBCOMMAND>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

SUBCOMMANDS:
    dns-name        search instance DNS name with query. [aliases: dns]
    help            Prints this message or the help of the given subcommand(s)
    info            search instance basic info with query.
    instance-ids    search instance ids with query. [aliases: ids]
    ips             search instance ips with query.

RustでCLIを作るためのパーツ

コマンドラインパーサ

cliといえばまずコマンドラインパーサです。 今回はstructoptを使いました。 こちらマクロにより自動でflag parse部分などを作ってくれるので大変便利でした。 またサブコマンドも簡単に作れるのでオススメです。

enumでコマンドを定義し、structでoptionを定義します。 以下具体例で見ていきます。

#[derive(Debug, StructOpt)]
pub enum InstanceOpt {
    #[structopt(about = "search instance basic info with query.")]
    Info(SearchQueryOpt),
}

#[derive(Debug, StructOpt)]
pub struct SearchQueryOpt {
    #[structopt(
        short = "q",
        long,
        help = "ambiguous search with asterisk on tag name. if set comma, search OR"
    )]
    query: String,
}

enumで定義されたInfoはそのままサブコマンドの info としてマクロ生成されます。 structで定義しているqueryは -q オプションとしてマクロ生成されます。

aws sdk

2020/12現在 rustのAWS公式SDKはないので、デファクトスタンダードとなっているrusotoを使います。 一つ難点としてはrusoto_ec2ではIDEIntelliJのrust plugin)の補完が効かないので、rust docとにらみ合いながら格闘しました。 rusoto_elbv2などは問題なかったのでマクロ生成されていると補完が効かないようです。 IntelliJではデフォルトではファイルサイズが2.5MBを超えるものはパースされないので、設定で変更する必要があります。 Help > Edit Custom Properties から以下の値を設定することで補完されるようになります。

# default is 20000 (単位はKB)
idea.max.content.load.filesize=1000000

# default is 2500 (単位はKB)
idea.max.intellisense.filesize=1000000

ただエディタのメモリ消費が増えるので設定には気をつけてください

実行バイナリ

実際の実行バイナリは [[bin]] で決められています。 nameの値を変更するとその名前でバイナリが吐かれます。 レポジトリ名はec2-searchですが長いのでバイナリとしては ec2s で吐いています。

[[bin]]
name = "ec2s"
path = "src/main.rs"

表示

やはり kubectl みたいにキレイに表示したいですよね。

ec2-searchでは cli-table というライブラリを使ってテーブル表示を行っています。 ライブラリを使うことで自分でスペースなどを計算しなくて済むのでCLI作りでは必須でしょう。

使い方は以下のような関数でwrapしてますが以下のように作ります。 cli-tableでは

  • 文字のスタイル
  • テーブルborderのスタイル
  • テーブルseparatorのスタイル

が指定できるので、好きなスタイルで表示できます

pub fn print_table(header: Vec<&str>, rows: Vec<Vec<String>>) {
    let bold = CellFormat::builder().bold(true).build(); // フォントの作成
    let h: Vec<Row> = vec![Row::new(
        header.iter().map(|h| Cell::new(h, bold)).collect(),  // boldを指定することでヘッダーのフォントを当てる
    )];
    let rows: Vec<Row> = rows
        .iter()
        .map(|r| Row::new(r.iter().map(|c| Cell::new(c, Default::default())).collect()))
        .collect();
    let r: Vec<Row> = h.into_iter().chain(rows).collect(); // headerと表示行をまとめる

    let border = Border::builder().build(); // borderのスタイル
    let separator = Separator::builder().build(); // separatorのスタイル
    let format = TableFormat::new(border, separator);

    let _ = match Table::new(r, format) {
        Ok(t) => t,
        Err(e) => panic!("{:?}", e),
    }
    .print_stdout();
}

f:id:yuutookun:20201207195411p:plain
cliの出力

CI

Rustの公式ドキュメントではTravisCIが紹介されていますが、最近のTravisCIはめちゃくちゃ遅いのでGitHubActionをオススメします。 CIでは以下2つを別で走らせています。 - style check - test

style checkとして以下を実行しています。 - rust fmt: インデントなどのコードフォーマットチェック - clippy: コード上でこう書いたほうがいいというサジェストをしてくれるlinter

jobs:
  style: # コーディングスタイルをチェックする
    runs-on: ubuntu-latest
    steps:
        - uses: actions/checkout@v2 # ファイルのチェックアウト
        - uses: actions-rs/toolchain@v1 # style jobで使うライブラリインストール
          with:
            toolchain: nightly
            components: rustfmt, clippy
            override: true

        - name: Check the format # cargo fmtを実行
          run: cargo +nightly fmt --all -- --check

        - name: Run clippy # cargo clippyを実行
          run: cargo clippy -- -D warnings
  test: # testの実行
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: actions/cache@v2 # libraryのキャッシュ
        with:
          path: |
            target
          key: ${{ runner.os }}-cargo-check-test-${{ matrix.toolchain }}-${{ hashFiles('**/Cargo.lock') }}

      - name: Build # cargo checkの実行
        run: cargo check
        env:
          CARGO_INCREMENTAL: 0
          RUSTFLAGS: "-C debuginfo=0 -D warnings"

      - name: Run tests # cargo testの実行
        run: cargo test --workspace
        if: ${{ runner.os == 'Linux' }}
        env:
          CARGO_INCREMENTAL: 0
          RUSTFLAGS: "-C debuginfo=0 -D warnings"

実際のCIは以下リンクです。 masterにPRを送るとCIが走ります。

ec2-search/test.yml at master · mocyuto/ec2-search · GitHub

CD

cliを使ってもらおうと思ったときに、「git cloneしてcargoコマンドをいれてcargo installしてください」と言われたらまぁ使ってもらえないですよね。 なので、brewでインストールできるようにします。

brewでインストールできるようにしてもらうには以下の手順が必要です。 linuxにも一応brewがあるので、一旦linuxbrewでいれてもらう想定です。 以下が手順になります。

  1. cliのバイナリを作成する
  2. GitHubのリリースページなどバイナリをダウンロードできる場所に置く
  3. brew本家はstarの数などで制限があるため、自前のbrewレポジトリを作る
    1. homebrew-hogeというレポジトリを作る
    2. GitHub - mocyuto/homebrew-tap がhomebrewを使うためのレポジトリ

しかし、これを毎度手動でやるのは大変なので、CDフローに任せましょう。

GitHubActionではtagを切ると

  1. バイナリリリース
    1. バイナリ作成(osx, linux, windowsそれぞれのバイナリを作ります)
    2. releaseページにアップロード
  2. crate.ioにpublish

をやってくれるようになっています。

バイナリのリリース

ビルドするときopenssl周りでビルドエラーが起こりうるので、opensslをOSでダウンロードしていますが、 それ以外はわかりやすいかなと思います。

secrets.GITHUB_TOKEN は特に何も設定しなくてもGitHub側で注入してくれるので楽ちんです。

  publish:
    strategy:
      matrix:
        os: [macos-latest, ubuntu-latest, windows-latest]
        rust: [stable]
        include:
          - os: macos-latest
            artifact_prefix: macos
            target: x86_64-apple-darwin
            binary_postfix: ""
          - os: ubuntu-latest
            artifact_prefix: linux
            target: x86_64-unknown-linux-gnu
            binary_postfix: ""
          - os: windows-latest
            artifact_prefix: windows
            target: x86_64-pc-windows-msvc
            binary_postfix: ".exe"
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v1
      - name: Installing Rust toolchain # library download
        uses: actions-rs/toolchain@v1
        with:
          toolchain: ${{ matrix.rust }}
          override: true
      - name: Installing needed macOS dependencies
        if: matrix.os == 'macos-latest'
        run: brew install openssl@1.1
      - name: Installing needed Ubuntu dependencies
        if: matrix.os == 'ubuntu-latest'
        run: |
          sudo apt-get update
          sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
      - uses: actions/cache@v2 # 実行高速化のためのキャッシュ
        with:
          path: |
            target
          key: ${{ runner.os }}-cargo-publish-${{ matrix.toolchain }}-${{ hashFiles('**/Cargo.lock') }}
      - name: Running cargo build # releaseビルド実行
        uses: actions-rs/cargo@v1
        with:
          command: build
          toolchain: ${{ matrix.rust }}
          args: --release --target ${{ matrix.target }}

      - name: Packaging final binary # packageのファイナルビルド
        shell: bash
        run: |
          cd target/${{ matrix.target }}/release
          strip ec2s${{ matrix.binary_postfix }} # デバッグ用のシンボルとかを削除する
          tar czvf ec2-search-${{ matrix.artifact_prefix }}.tar.gz ec2s${{ matrix.binary_postfix }} # tz圧縮
          # sha256 のハッシュ値がないとhomebrew でのダウンロード時に不正なソフトと言われるので以下は必ず入れる
          if [[ ${{ runner.os }} == 'Windows' ]]; then
            certutil -hashfile ec2-search-${{ matrix.artifact_prefix }}.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > ec2-search-${{ matrix.artifact_prefix }}.sha256
          else
            shasum -a 256 ec2-search-${{ matrix.artifact_prefix }}.tar.gz > ec2-search-${{ matrix.artifact_prefix }}.sha256
          fi
      - name: Releasing assets # releaseページに貼り付ける
        uses: softprops/action-gh-release@v1
        with:
          files: |
            target/${{ matrix.target }}/release/ec2-search-${{ matrix.artifact_prefix }}.tar.gz
            target/${{ matrix.target }}/release/ec2-search-${{ matrix.artifact_prefix }}.sha256
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

このリリースビルドを作る際に以下のレポジトリをすごく参考にしました。(というかほぼ同じ) 感謝 !!!

GitHub - Rigellute/spotify-tui: Spotify for the terminal written in Rust 🚀

crates.ioへのpublish

GitHubのレポジトリのSECRETにcrates.ioで発行されるAPI_KEYをコピーして貼りつけると使えるようになります。 crates.ioへのpublishはCargo.tomlのversionを見ているのでリリース前に変更するのを忘れないようにしましょう。

publish-cargo:
    name: Publishing to Cargo
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@master
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true
      - run: |
          sudo apt-get update
          sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
      - uses: actions-rs/cargo@v1
        with:
          command: publish
          args: --token ${{ secrets.CARGO_API_KEY }} --allow-dirty

実際はこんな感じです。

ec2-search/release.yml at master · mocyuto/ec2-search · GitHub

homebrew

homebrewからダウンロードしてもらうには、homebrew-hogeのレポジトリを作る必要があります。

ec2-searchのものは homebrew-tap/ec2-search.rb at master · mocyuto/homebrew-tap · GitHub です。

hoge部分の命名は基本何でもいいのですが、有名レポジトリは大抵 homebrew-tap という命名をしているのでtapがオススメです。 自分はec2-searchとつけてしまいました。。

以下例です

このレポジトリの命名によって tapコマンドの引数が変わります。

# mocyuto/homebrew-ec2-searchなので
$ brew tap mocyuto/ec2-search
# もし mocyuto/homebrew-tap なら
$ brew tap mocyuto/tap

細かい仕組みなどは割愛しますが、 リリースフローとしては

  1. main.rbのversionを変更
  2. バイナリ生成と同時に作成されるsha256のハッシュ値をセット
  3. PUSH

です。

バージョンを変更することでbrew upgradeで最新バージョンに更新できるようになります。

まとめ

Rustの内容というよりはCLIの作り方になってしまいましたが、Rustの事始めとしてのCLI作成は良い題材だと思います。 作っている際にCLIのバイナリを作るところまでの解説は多かったのですが、実際に運用するためのCI/CDの記述が少なく困ったので今回このような記事にさせていただきました。

ec2-searchも使っていただけると嬉しいです。

追記

yuutookun.hatenablog.com