本記事は Rust Advent Calenderの9日目の記事です。
今回はRustでCLIを作ったので、機能紹介と作る上でのポイントなどを紹介します。
書こうと思っていたネタが、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
ec2-searchというCLIを作りました。
実際のコマンドは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ではIDE(IntelliJの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(); }
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があるので、一旦linuxもbrewでいれてもらう想定です。 以下が手順になります。
- cliのバイナリを作成する
- GitHubのリリースページなどバイナリをダウンロードできる場所に置く
- brew本家はstarの数などで制限があるため、自前のbrewレポジトリを作る
- homebrew-hogeというレポジトリを作る
- GitHub - mocyuto/homebrew-tap がhomebrewを使うためのレポジトリ
しかし、これを毎度手動でやるのは大変なので、CDフローに任せましょう。
GitHubActionではtagを切ると
をやってくれるようになっています。
バイナリのリリース
ビルドするとき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とつけてしまいました。。
以下例です
- https://github.com/pinterest/homebrew-tap
- https://github.com/argoproj/homebrew-tap
- https://github.com/aws/homebrew-tap
このレポジトリの命名によって tapコマンドの引数が変わります。
# mocyuto/homebrew-ec2-searchなので $ brew tap mocyuto/ec2-search # もし mocyuto/homebrew-tap なら $ brew tap mocyuto/tap
細かい仕組みなどは割愛しますが、 リリースフローとしては
- main.rbのversionを変更
- バイナリ生成と同時に作成されるsha256のハッシュ値をセット
- PUSH
です。
バージョンを変更することでbrew upgradeで最新バージョンに更新できるようになります。
まとめ
Rustの内容というよりはCLIの作り方になってしまいましたが、Rustの事始めとしてのCLI作成は良い題材だと思います。 作っている際にCLIのバイナリを作るところまでの解説は多かったのですが、実際に運用するためのCI/CDの記述が少なく困ったので今回このような記事にさせていただきました。
ec2-searchも使っていただけると嬉しいです。