Screaming Loud

日々是精進

Rustのstructoptで作ったCLIにシェル補完をつける

引き続きCLIシリーズの紹介です

cliを作ったはいいものの、やはりコマンドのシェル補完はないと厳しいですよね。

structoptで作ったCLIでも簡単に補完のスクリプトを生成できるようになっています。 structoptのベースであるclap側にその機能があり、それをstructoptで呼び出すという感じです。

structopt

早速コードを見ていきましょう。 以下はec2-searchのコードを一部省いて載せています。

use std::io;
use structopt::clap::Shell;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
struct Cli {
    #[structopt(subcommand)]
    cmd: Command,
}

#[derive(Debug, StructOpt)]
enum Command {
    #[structopt(about = "Prints version information")]
    Version,
    #[structopt(about = "Prints Completion")]
    Completion(CompletionOpt),
}
#[derive(Debug, StructOpt)]
enum CompletionOpt {
    Zsh,
    Bash,
    Fish,
}

#[tokio::main]
async fn main() {
    match Cli::from_args().cmd {
        Command::Version => version(),
        Command::Completion(opt) => match opt {
            CompletionOpt::Bash => completion(Shell::Bash),
            CompletionOpt::Zsh => completion(Shell::Zsh),
            CompletionOpt::Fish => completion(Shell::Fish),
        },
    }
}

fn version() {
    println!("ec2-search {}", env!("CARGO_PKG_VERSION"))
}

fn completion(s: Shell) {
    Cli::clap().gen_completions_to(env!("CARGO_BIN_NAME"), s, &mut io::stdout())
}

Cli::clap() でclapの関数を呼べるようになっているので、これで補完スクリプトを生成する gen_completions_to を呼び出します。 生成したものを標準出力に出したいので、stdoutに吐いています。

実際のコードは以下リンク先です。

github.com

homebrewに対応

brewでインストールさせている場合、tapのスクリプトに以下を設定することでインストール時にシェルのcompletionの設定ができます

 def install
    bin.install 'ec2s'

    # bash completion
    output = Utils.safe_popen_read("#{bin}/ec2s", 'completion', 'bash')
    (bash_completion / 'ec2s').write output
    # zsh completion
    output = Utils.safe_popen_read("#{bin}/ec2s", 'completion', 'zsh')
    (zsh_completion / '_ec2s').write output
  end

この記述は何をしているかというとbashzshの補完スクリプトを各シェルの補完用ディレクトリに保存しています。 実際には以下のようなコマンドを実行しているのと等価です。

bashの場合

$ ec2s completion bash >  /usr/local/etc/bash_completion.d/ec2s

zshの場合

$ ec2s completion zsh >  /usr/local/share/zsh/site-functions/_ec2s

実際のコードは以下リンクです

homebrew-tap/ec2-search.rb at cd8f5e4dc0201a0a670d737a3dde5007ba7ba726 · mocyuto/homebrew-tap · GitHub

ハマったところ

clapには conflicts_with というオプションを複数指定した場合に使っていないオプションを指定していると以下のような実行時エラーが発生します。

$ cargo run completion zsh
   Compiling ec2-search v0.9.1 (/Users/yuto/GitHub/ec2-search)
    Finished dev [unoptimized + debuginfo] target(s) in 26.67s
     Running `target/debug/ec2s completion zsh`
thread 'main' panicked at 'Fatal internal error. Please consider filing a bug report at https://github.com/clap-rs/clap/issues', /Users/yuto/.cargo/registry/src/github.com-1ecc6299db9ec823/clap-2.33.3/src/completions/zsh.rs:346:29
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

denoでも同じようなバグが発生しているので、 conflicts_with を使う場合気をつけましょう。

github.com

以下のように生成部分をテストに含めて壊れないかチェックしておくとよいでしょう。 https://github.com/mocyuto/ec2-search/blob/v0.9.1/src/main.rs#L56-L61

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

リードエンジニアとしての役割

ちょうどリードエンジニアを任せてもらって1年が立ちました。 広告チームのリードエンジニアを任せてもらってから、自分のリードエンジニアとしての価値はなんだろうと考え、色々実行にうつしてきたことを。

リードエンジニアの役割

弊社のリードエンジニアのミッションとは以下です。

高い技術力ならびに、事業ドメインへの深い知識を持ち、
事業におけるトップエンジニアとしてプロダクトの成長に貢献する

リードエンジニアができる以前は、エンジニアが持つ役職はマネージャしかありませんでした。 マネージャから部長、役員というように役職があります。

以下記事でも述べられているように、

  • 技術的意思決定のデリゲーション
  • 育成とキャリア

を担って欲しいという思いで設置された背景があります。

tech.gunosy.io

マネージャに関してはピープルマネジメント、部長に関しては事業の責任を持つという役割があります。

自分のリードエンジニアとしての価値とは

さて、自分がリードエンジニアとして任命されたわけですが、マネージャと違って決まった役割がありません。 マネージャであれば、マネージャ研修があったり、1on1のように各メンバーとの定期的な対話が必須となってきます。

リードエンジニアは横断タスク(例えばインフラコストの削減など)を実施しますが、 マネージャなどと違いタスクを実施するのはメンバーであっても可能です。

では自分のリードエンジニアとしての価値はなんだろうと考えました。

1. 技術力

自分は一応技術スタックとしてインフラからフロントエンド、アプリSDKまで経験しているので幅は広いですが、 深いコアなスタックはないと思っています。 技術力とはなにかというものに明確な答えは未だにないのですが、ある問題を解決するための手札を持っているかどうかなのかなと思っています。

2. エンジニアメンバーと視点を変える

イチエンジニアのときと視点を変える必要があるなと思いました。 単純に一つの技術(例えばGoだったり)に関してすごく詳しくなるというのはテックリードとしてはあると思うのですが、 自分は割とひたすら深堀るというより幅広く手を出したくなるタイプなので単一の技術ではあまり尖っていないと自覚しています。 そういう前提のもとで自分が価値を出せるものはなんだろうと考えたときに

  • なにか仕組みを作る
  • 一つやったらスケールする基盤

かなと思いました。

3. 切磋琢磨

育成という言葉はなんか上下関係があるみたいで嫌なので、代替する言葉だと切磋琢磨がしっくり来ています。 どんどん技術は進歩しているので自分も学ぶ必要があるし、同時にチームみんなの技術力の底上げもしていきたい。 もちろんチーム内のメンバーのでそれぞれ詳しい技術領域があるし、自分が一番とは全く思っていないので、 それぞれの詳しい領域をどんどん吸収していきたいし、自分が詳しい領域はどんどん教えていきたい。 なんかせっかくリードエンジニアになったので、そういう空気を作りたいなと考えました。

トライしたこと

リードエンジニアになっていくつかトライしたものたち

  • 技術1on1
    • チーム内勉強会
  • 失敗共有会

技術1on1

弊社では毎月マネージャと1on1を実施します。 一般的な1on1なのですが、込み入った技術的な話までカバーはできていない現状だったので、 じゃあリードエンジニアとして技術的な相談やエンジニアとしての相談をする機会を作ってみようというところから始めました。

もちろん通常の1on1をやっているので、それと話す内容がほぼかぶってしまうのであれば意味がないので 必要ないと意見が出たらすぐやめるというスタンス、頻度は2ヶ月に1回ではじめました。 結果、今の所ずっと続いています。 またコロナでコミュニケーションが減ったのでそれを埋める役割にも多少なってるかなと思ったりもしてます。

そして話したことを各メンバーとのGoogleDocsで共有してますが、一番上に何のためにするか?という目的を書いて やってる事自体がなぁなぁにならないようにしています。

何のために1on1 をするのか?
- 技術的に不満はあるか
- 技術的成長感はあるか
- 技術的な成長の方針相談
- 自分と雑に話したい

この技術1on1でインフラを強化したいという話が出てきて、チーム内勉強会を開催しました。 題材はblackbelt勉強会です。

aws.amazon.com

blackbeltは自分で資料を作る必要がないのでコスト低く開催できるのでオススメです。

失敗共有会

こちらは仕組み作りとしてトライしたものです。

弊社では障害が起きた場合、障害報告書を記載します。 この障害報告書を書く目的は、再発防止策などや恒久対応をどのようにやったかというのを全社として共有するためにやっています。 しかしこれ自体はストックとして残っているのですが、それをちゃんと読んでいる人は実際関係者のみのように感じていました。 せっかく残していく文化があってもそれが共有されていないともったいないと思い、共有する場を作ろうと思いました。

そしてこの会は、失敗したことを共有するのが目的ではなく、 失敗に対してどういうリカバリーをしたかどういう再発防止策をしたかというのをみんなに共有することが目的です。

また名前も親しみやすいのがいいなーと思って「はにびぶ会」という名前にしました。

まだ開催回数が少なく定着するかはわからないですが、 このような個人やチーム内でとどまってしまっている知見を引っ張り出して全社で共有する場は残していきたいなぁと思ってるので、 やっても無駄になるまではピポットをしてやっていきたいなと思っています。

まとめ

弊社は優秀なエンジニアが多いのでリードエンジニアとしてどのような価値が出せるかということを日々考えさせられます。 またなんかトライしてるけどどうしてそんなことしてるのみたいなのを文章で残しておきたいなと思って書きました。

上記のようなトライをしてみているのですが正解はもちろんないので、 みなさんからフィードバックをもらいながらより良い環境を作っていければと思います。

さらさらビーフカレー

今日は「さらさらビーフカレー」です。 このカレーはアコメヤという店で売っているもので、普通のスーパーとかでは売っていません。

f:id:yuutookun:20201002221408j:plain

名前の通りスープカレーのような、さらさら感のカレーです。

www.akomeya.jp