見出し画像

Bun shellを使って快適にスクリプトを書こう(2) AWS CLIを使った実用例

こんにちは、はしるとりです。
ナビタイムジャパンでSREを担当しています。

前回の記事でBun shellの基本的な使い方と、CLI引数の受け取りやパラメータファイルのロードなどシェルスクリプトでありがちな処理をBun shellで簡単にできることを説明しました。
今回はその続きとして、AWS CLIやAWS SDK for JavaScript v3と組み合わせた利用例を紹介します。


aws-sdk を追加

❯ mkdir bun-aws-sdk
❯ cd bun-aws-sdk
❯ bun init
bun add @aws-sdk/credential-providers @aws-sdk/client-s3 @aws-sdk/client-cloudformation

STS AssumeRoleして得たクレデンシャルを使ってAWS CLIを実行する

アカウントをまたいだ操作をする際に、STS AssumeRoleを使用している場合は以下のような関数を用意しておくとよいでしょう。

import { fromTemporaryCredentials } from "@aws-sdk/credential-providers";
import type {
  AwsCredentialIdentity,
  AwsCredentialIdentityProvider,
} from "@smithy/types";
import { $ } from "bun";

/**
 * 任意のアカウントに対してAssumeRoleして、sessionの期限が切れたら自動で更新するヘルパー
 */
export function autoRefreshCredentialProvider(
  account: string,
  region: string,
  role: string,
  sessionName: string,
): AwsCredentialIdentityProvider {
  const credentialProvider = fromTemporaryCredentials({
    params: {
      RoleArn: `arn:aws:iam::${account}:role/${role}`,
      RoleSessionName: `${sessionName}`,
      DurationSeconds: 300,
    },
    clientConfig: {
      region,
    },
  });

  let credential: AwsCredentialIdentity | null = null;

  return async (): Promise<AwsCredentialIdentity> => {
    const now = new Date();
    if (
      credential?.expiration != null &&
      credential.expiration?.getTime() >= now.getTime()
    ) {
      return credential;
    }

    credential = await credentialProvider();
    return credential;
  };
}

/**
 * コマンドをAWS関連の環境変数をつけて実行する
 */
export const createAwsCliClient = (
  credProvider: AwsCredentialIdentityProvider,
) => {
  return async (cmds: ReadonlyArray<string>) => {
    const cred = await credProvider();
    return await $`AWS_SESSION_TOKEN=${cred.sessionToken} \
      AWS_ACCESS_KEY_ID=${cred.accessKeyId} \
      AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey} \
      ${cmds}`;
  };
};

次のようにして使います

const awsCliClient = createAwsCliClient(
  autoRefreshCredentialProvider(
    "00000000000",
    "ap-northeast-1",
    "MyRole",
    "my-sesssion",
  ),
);

await awsCliClient(["aws", "s3", "ls", "s3://my-bucket"]);

`await awsCliClient("aws s3 ls s3://my-bucket");` ではなくstringの配列を渡しているのは、文字列全体が一つのコマンドとして解釈されcommand not foundとなるためです。

ShellError: Failed with exit code 1
 info: {
  "exitCode": 1,
  "stderr": "bun: command not found: aws s3 ls\n",
  "stdout": ""
}

S3からオブジェクトをダウンロードする

シンプルにコマンドライン引数で渡されたファイルをダウンロードする処理です。

const { values, positionals } = parseArgs({
  args: Bun.argv.slice(2),
  options: {
    output: {
      type: "string",
      short: "o",
      default: "./",
    },
  },
  strict: true,
  allowPositionals: true,
});

const source = positionals[0];
await awsCliClient(["aws", "s3", "cp", source, values.output!]);
❯ bun run index.ts -o test.log s3://log-bucket/20240102150405.log
download: s3://log-bucket/20240102150405.log to ./test.log

CloudFormationのstackを作成する

CloudFormationでstackを作成するとき、可変要素を `Parameters` で定義して `--parameter-overrides` で指定する場合があります。

aws cloudformation deploy \
  --stack-name ${stack_name} \
  --template-file ./template.yaml \
  --parameter-overrides \
    RoleArn=${role_arn} \
    Size=${size} \
    ...

このパラメータを環境ごとにJSONやYAMLで用意しておいて `jq` や `yq` でパースする処理を書くと、パラメータ数や階層の深さに応じてスクリプトが複雑になっていきます。
これをTypeScriptにすると見通しの良いコードになり、配列の操作などもわかりやすくなります。

interface Param {
  stackName: string;
  roleArn: string;
  size: string;
  instanceTypes: string[];
}
const templateBody = await Bun.file("template.yaml").text();
const paramText = await Bun.file("params.yaml").text();
const param = load(paramText) as Param;

await awsCliClient([
  "aws",
  "cloudformation",
  "deploy",
  `--stack-name=${param.stackName}`,
  "--template-file=template.yaml",
  "--parameter-overrides",
  `RoleArn=${param.roleArn}`,
  `Size=${param.size}`,
  `InstanceTypes=${param.types.join(',')}`,
  // ....
]);

クロスコンパイルしてシングルバイナリを作成する

ここまで利用例を紹介してきました。続いて実行環境について説明します。

Bunは単独で実行可能なファイルを生成することができます。
シングルバイナリを配布することで、実行する環境にはBunをインストールすることなく実行可能です。

さらに2024/04/26にリリースされたBun 1.1.5で、GoやRustのようにクロスコンパイルできる機能が追加されました。
これにより、macOS(M1)上でビルドした実行ファイルをLinux(x86_64)上で実行するといったことができます。

ホスト: macOS(M1)、実行環境: Linux(on Docker) で実験してみます。

なにもオプションをつけずにビルドすると、実行時にexec format errorとなります。

❯ bun build --compile index.ts

❯ docker run --rm -v $(pwd):/work -w /work public.ecr.aws/docker/library/debian:bookworm-slim ./index
exec ./index: exec format error

`--target`オプションを指定することで、実行ファイルのターゲットプラットフォームでビルドされ、正しく実行できます。

❯ bun build --compile --target=bun-linux-arm64 index.ts

❯ docker run --rm -v $(pwd):/work -w /work public.ecr.aws/docker/library/debian:bookworm-slim ./index
Hello Bun shell!

ただし、GoやRustの実行ファイルと比べるとサイズがなかなか大きいので、転送時間や容量と相談になるかなと思います。

❯ du -sh index
90M     index

おわりに

ここまで2回にわたって、Bun shellとAWS CLI/SDKを組み合わせた利用例を紹介してきました。
JavaScript/TypeScriptに慣れ親しんでいる方であれば、すぐに書けるようになるのではないかと思います。
npmのモジュールも使えて既存のエコシステムを活用できるのも大きな利点です。
シェルスクリプトに苦手意識があるけれどどうしても必要になった、というときに使ってみてください。