AWS SDK for Rust × fuzzy-finder で AWS CLIを超絶パワーアップする

# 1 行まとめ
ついに GA になった AWS SDK for Rust を使って以下のような CLI を作るまでの流れを簡単になぞります。

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254034/c10eb358-3d95-438f-24da-083d203bedb9.gif)

この記事は [AWS for Games Advent Calendar 2023](https://qiita.com/advent-calendar/2023/aws-for-games) の 10 日目の記事です。
あんまりゲームじゃないですが、DevEX 向上は最終的にゲーム開発の高速化につながるはずだ、ということで


# :page_with_curl:はじめに:page_with_curl:
2023 re:Invent では多くのアップデートがありましが、その中にあまり話題になっていない（※個人の意見です）しかし重大なアップデートがありました。
それがこれ(バァン)

https://aws.amazon.com/blogs/developer/announcing-general-availability-of-the-aws-sdk-for-rust/

というわけで、今回は AWS CLI の単体のコマンドでは実現できない、ちょっと面倒くさい操作を、新しいコマンドラインツールとして便利にするまでの手順を紹介したいと思います。
本記事は AWS for Games Advent Calendar 2023 の 12/10 の記事となるため、数ある AWS サービスの中でも特に Amazon GameLift のコマンドを対象にしたいと思います。


## :page_with_curl:セットアップ ~ AWS API 呼び出し:page_with_curl:
https://www.rust-lang.org/ja/tools/install
Rust の環境がない場合、上を参考にセットアップできます。やりましょう。
セットアップができたら、プロジェクトを作成します。 たぶんとんでもなく簡単です。

```shell
cargo new super-aws-cli
cd super-aws-cli
cargo run
```

 `cargo run`を実行し、 `Hello, world` が出力されれば OK です。
初回の実行時は依存パッケージのインストールが走るので少々時間がかかるはずです。気長に待ちましょう。


次に AWS の API を適当に呼び出ししてみます。今回は Amazon GameLift を使いたいのですが、残念ながらサンプルはなさそうなので、ドキュメントを確認します。
https://github.com/awslabs/aws-sdk-rust/tree/main/sdk/gamelift
確認した感じ、依存関係は以下のように記述すれば良さそうです。バージョンは適宜確認しましょう。

```Cargo.toml
[dependencies]
aws-config = { version = "1.0.1", features = ["behavior-version-latest"] }
aws-sdk-gamelift = "1.3.0"
tokio = { version = "1", features = ["full"] }
```

ここまでできれば、あとは実現したい処理をどんどん追加していくだけです。とはいえ、その処理をどうやって書けばいいのかといううところが気になるところかと思います。まずは、List 系 API を叩いて結果を表示するだけのコードを参考として以下に記述します。
```Rust
use aws_sdk_gamelift as gamelift;

#[::tokio::main]
async fn main() -> Result<(), gamelift::Error> {
    // 認証情報を読み込み
    let config = aws_config::load_from_env().await;
    let client = aws_sdk_gamelift::Client::new(&config);

    let response = client.describe_fleet_attributes().send().await?;
    dbg!(&response);
    // フリートごとにレスポンスに含まれるフリートIDを表示
    let fleet_attributes = response.fleet_attributes.expect("describe_fleet_attributes response is invalid format");
    for attributes in fleet_attributes {
        println!("Fleet ID: {}", attributes.fleet_id.unwrap_or("Cannot retrieve Fleet ID".to_string()));
    }
    Ok(())
}
```

これで実行すると、`response`のデバッグ情報が流れた後、FleetID がちゃんと取得できてることがわかります。
はい、もうここまでできれば実質、任意の操作を自作できます。

```shell
[src/main.rs:14] &resp = DescribeFleetAttributesOutput {
    fleet_attributes: Some(
        [
<デバッグ情報は長いので省略>

Fleet ID: fleet-xxx-xx-xx-xx-xx
Fleet ID: fleet-xxx-xx-xx-xx-xx
```


## :thinking:自作 AWS コマンドの検討:thinking:
:fire:超絶:fire:パワーアップした CLI を作成するためには、現在の不便な点の振り返りと理想のコマンドの定義が必要です。Amazon GameLift で考えると、現状、検証時にゲームサーバーが吐き出して GameLift に保管するログをターミナルから確認しようとすると結構手間がかかります。これが簡単にできればもう:fire:超絶:fire:パワーアップ、といっても差し支えないでしょう。

というのもの、現状ログを取得するためには、以下の手順を踏む必要があります。
- フリートを選択 (FleetID を事前に決めうちしておくか、DescribeFleets API を実行)
- その後ゲームセッションを選択 (GameSessionID を事前に決め打ちするか、ListGameSession を実行し任意に選ぶ)
- ゲームログをダウンロード (AWS CLI であれば GetGameSeesionLog を実行して保存する。そうでない場合 GetGameSessionLogURL を実行して取得した署名付き URL からダウンロードする)
- ダウンロードしたログを確認 (Zip を 解凍しエディタで開く)

<br>

こちら定型のワークフローなどであれば、事前に取得すべき GameSessionID が他から渡されているため簡単なのですが、検証時だと、適当に今さっき作ったフリートの適当なログを見たい、といったケースが多々発生します。そういったケースで何度もコマンドを叩いてると、時間もかかりますし結構めんどくさい、というのが正直なところです。
これはインフラストラクチャ側でログ関係のソリューションを導入することで解決できそうですが、今回はクライアント側で解決することに取り組んでみようと思います。


## :thinking:超絶パワーアップコマンド実現方法の検討:thinking:
今回の課題を解決した理想の姿としては、一回のコマンド操作でログをいい感じに見れるようになっていることかと思います。
しかし、事前にログを見たいフリーとやゲームセッションのIDが決まっていない場合もあるので、コマンド実行中にいい感じにやりとりする必要があります。

ランダムに適当に値を取ってくるのもありあですが、今回はこれを実現する方法の一つとして [fuzzy finder](https://github.com/junegunn/fzf) が使えそうです。
Rust では有名な fzf ライブラリとして [skim](https://github.com/lotabout/skim) が利用可能なので今回はこちらを使いたいと思います。

<br>
全体の方針としては、 List 系の API を叩いた後、fzf に取得した文字列を流して選択　
という作業を繰り返し、gamesessionID が定まった時点でゲームセッションログを取得。Zip圧縮を解凍して画面に表示という流れにします。


## :writing_hand:実装:writing_hand:
最終的なパッケージは以下の通りになりました。 
```Cargo.toml
[dependencies]
aws-config = { version = "1.0.1", features = ["behavior-version-latest"] }
aws-sdk-gamelift = "1.3.0"
curl = "0.4.44" # S3 署名付き URLから Zip 圧縮されたデータを取得するため
skim = "*" # fuzzy-finder を使用するため
tokio = { version = "1", features = ["full"] }
zip = "0.6.2" # 取得したログデータを解凍する
```

サンプルコードは以下です。今回は単一コマンドだけ実装し、引数もいらないので [clap](https://github.com/clap-rs/clap) クレートは使用しませんが、非常に便利なのでリンクだけ紹介します。
(※自分用で使うコードのため、エラーハンドリングや記述に甘い部分がありますのでご容赦ください。事前にチェックはしていますが、特に致命的なものはコメントいただけると嬉しいです)
```Rust 
use aws_sdk_gamelift as gamelift;
use curl::easy::Easy;
use skim::prelude::*;
use std::io::Cursor;

#[::tokio::main]
async fn main() -> Result<(), gamelift::Error> {
    let config = aws_config::load_from_env().await;
    let client = aws_sdk_gamelift::Client::new(&config);

    // Select a FleetID from IDs
    let describe_fleet_attributes = client.describe_fleet_attributes().send().await?;
    if describe_fleet_attributes.fleet_attributes().is_empty() {
        panic!("No Fleets exist.");
    }
    let fuzzy_string: Vec<String> = describe_fleet_attributes
        .clone()
        .fleet_attributes
        .unwrap()
        .iter()
        .map(|i| i.fleet_id().unwrap().to_string())
        .collect();
    let index = fzf(fuzzy_string.join("\n"), "Selected Fleet : ").await;
    let fleet_id = describe_fleet_attributes
        .fleet_attributes
        .unwrap_or_default()[index]
        .fleet_id()
        .unwrap()
        .to_string();

    // Select a SessionID from IDs
    let describe_game_sessions = client
        .describe_game_sessions()
        .fleet_id(fleet_id)
        .send()
        .await?;

    if describe_game_sessions.game_sessions().is_empty() {
        panic!("No GameSessions exist.");
    }
    let game_sessions = describe_game_sessions.game_sessions.unwrap_or_default();
    let fuzzy_string: Vec<String> = game_sessions
        .iter()
        .map(|i| i.game_session_id().unwrap().to_string())
        .collect();
    let index = fzf(fuzzy_string.join("\n"), "Selected GameSession : ").await;
    let game_session_id = game_sessions[index].game_session_id().unwrap().to_string();

    // Get pre-signed URL from GameSessionID
    let get_game_session_log_url = client
        .get_game_session_log_url()
        .game_session_id(game_session_id)
        .send()
        .await?;
    let presigned_url = get_game_session_log_url
        .pre_signed_url()
        .unwrap()
        .to_owned();

    // Get objects from S3
    let mut data = Vec::new();
    let mut handle = Easy::new();
    handle.url(&presigned_url).unwrap();
    {
        let mut transfer = handle.transfer();
        transfer
            .write_function(|new_data| {
                data.extend_from_slice(new_data);
                Ok(new_data.len())
            })
            .unwrap();
        transfer.perform().unwrap();
    }

    // Select file from files
    let reader = std::io::Cursor::new(data);
    let mut zip = zip::ZipArchive::new(reader).unwrap();
    let mut files: Vec<String> = Vec::new();

    for i in 0..zip.len() {
        let f = zip.by_index(i).unwrap();
        files.push(f.name().to_string());
    }

    let index = fzf(files.join("\n"), "? Select log file : ").await;

    // Unzip file and display
    zip.by_index(index).unwrap();
    let contents = String::new();

    fzf(contents, "Selected record : ").await;

    Ok(())
}

async fn fzf(input: String, title: &str) -> usize {
    print!("{} ", title);
    let options = SkimOptionsBuilder::default()
        .height(Some("90%"))
        .multi(false)
        .reverse(true)
        .build()
        .unwrap();

    let item_reader = SkimItemReader::default();
    let items = item_reader.of_bufread(Cursor::new(input.clone()));

    let selected_items = Skim::run_with(&options, Some(items))
        .map(|out| out.selected_items)
        .unwrap_or_else(|| Vec::new());

    let selected_string = selected_items[0].output().to_string();
    println!("{}", selected_string);

    let stringvec = input.split('\n').collect::<Vec<&str>>();
    stringvec
        .iter()
        .position(|&r| r == selected_string)
        .unwrap()
}

```

## :runner:実行:runner:
コマンドラインツールとして使うためバイナリにしてから実行します。リリースプロファイルでビルドすることでデバッグ時と比べ高速に動作するようになります。
```
cargo build --release
```

ビルドバイナリは以下ディレクトリに保存されているので、カレントディレクトリから以下実行することで呼び出しできます。
```
./target/release/super-aws-cli
```

ということで、実際に作ったツールを試してみた結果が以下となります。本来だと4~5回のコマンド実行が必要なところ、インタラクティブにやりとりすることで一回のコマンド実行でそのまま、実行されたゲームのログの確認までできます。
これはデバッグ捗ること間違いなし！:fire:超絶:fire:パワーアップされた CLI と考えても問題ないでしょう(自賛:sunglasses:)

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254034/98ab3185-4f8d-9a0c-cca2-1e483e007a6e.gif)


## まとめ
今回は Amazon GameLift をテーマに取り上げましたが、ユースケースとしては他のサービスでもある内容だと思います。
たとえば、SSM 経由で EC2 にリモートログインする際は、[SessionManager Plugin](https://github.com/aws/session-manager-plugin)を使う必要があります。この Plugin と EC2 系の API を fzf で繋ぐことで、一覧から選択してリモートログインといった作業があれば超絶快適に可能になりそうです！
適当な Lambda 関数を選択して　CloudWatch Logs のログを適当に取ってくる、とかもできると構築時に結構嬉しそうな気がします。

もちろん、すでに特定の AWS サービスの利用に特化した CLI ツール [rain](https://github.com/aws-cloudformation/rain) や [dynein](https://github.com/awslabs/dynein) などを利用していくのもいい選択肢だとは思いますが、生成 AI の登場により実装難易度が下がりつつある現在、サクッとツール作れるようになるとより痒いところに手が届くようになるのではないかと思います。
この記事をきっかけに CLI を作ってみようという流れが起きると嬉しいなと思います。


(免責) 本記事の内容は個人の見解で、所属企業や団体の見解ではない点はご留意ください。