AWS SDK for JavaScript v3で多要素認証(MFA)をしてAssumeRole(スイッチロール)やEC2インスタンスを作成してみた

AWS SDK for JavaScript v3の触ってみた系のブログが少なかったので触ってみました
2021.08.01

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

v2とv3があったらv3を触ってみたくなるのが人の性

こんにちは、のんピ です。

皆さんはAWS SDK for JavaScript v3を触ったことはありますか? 私はありません。

AWS SDK for JavaScript v3は、こちらのwhat's newにある通り、2020/12/15に一般公開されています。

AWS SDK for JavaScript v2とAWS SDK for JavaScript v3があったら、より新しいAWS SDK for JavaScript v3を触ってみたくなるのが人の性というものです。

リリースされた半年以上経った2021年8月現在でも、触ってみた系の記事が少ないのもあり、AWS SDK for JavaScript v3 を今回触ってみようと思います。

AWS SDK for JavaScript v3で処理する内容としては、以前私が書いた「AWS SDK for JavaScriptで、『多要素認証(MFA)をしてAssumeRole(スイッチロール)』するスクリプトを書いてみた」と同様の処理と、EC2インスタンスの作成をやってみようと思います。

そもそもAWS SDK for JavaScript v3になって何が変わったの?

そもそもAWS SDK for JavaScript v3になって何が変わったのかを確認してみます。

AWS公式ドキュメントには以下のように書かれています。

  • パッケージのモジュール化
    • ユーザーはサービスごとに個別のパッケージを使用できるようになった。
  • ミドルウェアスタックの導入
    • ユーザーはミドルウェアスタックを使用して、オペレーション呼び出しのライフサイクルを制御できるようになった。
  • AWS SDK for JavaScript自身がTypeScriptで書かれている
    • 静的型指定など多くの利点がある。

以下、それぞれの項目の詳細を確認していきます。

パッケージのモジュール化

AWS SDK for JavaScript v2は、SDKが単一のパッケージとして公開されています。そのため、

  • EC2インスタンスの操作だけしたいのに、IoTとか使わないサービスもインポートされて、パッケージのサイズが膨れてしまう...

といったことがありました。

AWS SDK for JavaScript v3ではサービスごとにモジュール化されており、上述した問題が解消されています。

AWS SDK for JavaScript v2とAWS SDK for JavaScript v3のコードを比較すると、importや一連の処理の書き方が異なることが一目瞭然だと思います。

AWS SDK for JavaScript v2でEC2インスタンスの一覧を表示する場合

import * as AWS from "aws-sdk";

const ec2 = new AWS.EC2({});
ec2.describeInstances((error, instances) => {
  if (error) {
    console.error("Describe the EC2 Instance error!! \n\n", error);
  } else {
    console.log(JSON.stringify(instances, null, 2));
  }
});

AWS SDK for JavaScript v3でEC2インスタンスの一覧を表示する場合

import {
  EC2Client,
  DescribeInstancesCommand,
} from "@aws-sdk/client-ec2";

const ec2Client = new EC2Client({});
await ec2Client.send(new DescribeInstancesCommand({}))
  .then((instances) => {
    console.log(JSON.stringify(instances, null, 2));
  })
  .catch((error) => {
    console.error("Describe the EC2 Instance error!! \n\n", error);
  });

また、実際にnode_modulesのサイズを比較すると、20MB程度AWS SDK for JavaScript v3の方が少ないことが分かります。

AWS SDK for JavaScript v2のnode_modules

AWS SDK for JavaScript v3node_modules

Lambdaやコンテナイメージなど、パッケージのサイズをなるべく減らしたい時に重宝しそうですね。

モジュール化されたパッケージされた恩恵については、他にもユーティリティ関数のインポートなどがあります。詳細は以下AWS公式ドキュメントをご確認ください。

v2で作成されたバンドルとv3で作成されたバンドルのロード時間の比較なども記載してあり、必見です。

ミドルウェアスタックの導入

まず、ミドルウェアスタックとは何か、という確認ですが、AWS公式ドキュメントでは以下のように説明してあります。

The JavaScript SDK maintains a series of asynchronous actions. These series include actions that serialize input parameters into the data over the wire and deserialize response data into JavaScript objects. Such actions are implemented using functions called middleware and executed in a specific order. The object that hosts all the middleware including the ordering information is called a Middleware Stack. You can add your custom actions to the SDK and/or remove the default ones.

When an API call is made, SDK sorts the middleware according to the step it belongs to and its priority within each step. The input parameters pass through each middleware. An HTTP request gets created and updated along the process. The HTTP Handler sends a request to the service, and receives a response. A response object is passed back through the same middleware stack in reverse, and is deserialized into a JavaScript object.

日本語でおk」というところですが、私は以下の認識をしました。(間違っていたらこっそり教えてください...)

  • JavaScript SDKには一連の非同期処理があり、これらの処理は「ミドルウェア」という機能で特定の順番で処理されるように実装されている
  • このミドルウェアの全てをホストするオブジェクトを「ミドルウェアスタック」と呼ぶ
  • ミドルウェアスタック(以降スタック)が導入されたことで、ミドルウェアを変更して新しい処理を追加したり、デフォルトの処理を削除したりなど、SDKの動作をカスタマイズできる

イマイチピンと来ませんね。AWS公式ドキュメントに記載されている、S3のオブジェクトメタデータにカスタムヘッダーを追加するコードを確認して理解を深めてみます。

const { S3 } = require("@aws-sdk/client-s3");
const client = new S3({ region: "us-west-2" });
// Middleware added to client, applies to all commands.
client.middlewareStack.add(
  (next, context) => async (args) => {
    args.request.headers["x-amz-meta-foo"] = "bar";
    const result = next(args);
    // result.response contains data returned from next middleware.
    return result;
  },
  {
    step: "build",
    name: "addFooMetadataMiddleware",
    tags: ["METADATA", "FOO"],
  }
);

await client.putObject(params);

S3クライアントのmiddlewareStackaddというメソッドがあります。

まず、middlewareStackのメソッドを確認してみます。AWS公式ドキュメントMiddlewareStackを確認してみると、以下のようなメソッドがあることが分かります。

  • add
    • ミドルウェアをスタックに追加し、オプションでライフサイクルステップ、優先度、タグ、名前などを指定する
  • addRelativeTo
    • 既存のミドルウェアの前または後にミドルウェアを追加し、オプションでライフサイクルステップ、優先度、タグ、名前などを指定する
  • clone
    • 対象のスタックのクローンを作成する
  • concat
    • 対象のスタックのミドルウェアと、fromで指定したスタックのミドルウェアを含むスタックを作成する
  • remove
    • ミドルウェアをスタックから削除する
  • removeByTag
    • 指定されたタグを含むミドルウェアを削除する
  • use
    • スタックを変更する関数を適用する

AWS公式ドキュメント曰く、どうやらaddというメソッドは、ミドルウェアをスタックに追加するようです。名前のままですね。

続いて、addメソッドの引数を確認します。

またまたAWS公式ドキュメントを確認してみると、addメソッドは、1つ目の引数でミドルウェアを指定し、2つ目の引数でライフサイクルステップ、タグ、名前を指定するようです。

今回の例の場合、buildというライフサイクルステップで、x-amz-meta-fooというリクエストヘッダーにbarという値を設定するミドルウェアを追加しているということですね。

ライフサイクルステップという単語が急に出てきたので、ライフサイクルステップについて補足します。スタックにはリクエストのライフサイクルを管理するための以下5つのステップがあります。

  • initialize
    • APIコールを初期化する
    • このステップでは、まだHTTPリクエスト構築されていない
    • 例) コマンドへのデフォルトの入力値の追加
  • serialize
    • APIコールのHTTPリクエストを構築する
    • 例) 入力の検証や、ユーザーの入力からのHTTPリクエストの構築
  • build
    • シリアライズされたHTTPリクエストをビルドする
    • 例) Content-Lengthbody checksumなどのHTTPヘッダーを追加
  • finalizeRequest
    • HTTPリクエストを送信するための準備をする
    • 例) リクエストの署名、リトライの実行、接続制御(Hop-by-hop)ヘッダーの追加
  • deserialize
    • 生のレスポンスオブジェクトを構造化されたレスポンスにデシリアライズする

ミドルウェアを追加する際には、HTTPリクエストの状態(ライフサイクル)を指定しろということですね。

AWS SDK for JavaScript自身がTypeScriptで書かれている

AWS SDK for JavaScript v3は、SDK自身がTypeScriptで書かれ、JavaScriptにコンパイルされています。

実際に、node_modules/@aws-sdk/client-ec2を確認すると、.tsファイルがあり、distディレクトリ配下にコンパイルされたと思われる.jsファイルがあります。

TypeScriptで書かれていることにより、静的な型チェックや、クラスやモジュールのサポートなどTypeScriptの利点をフル活用できます。

個人的には「SDKの中身はどう書かれているんだ」という場合に直面することが多いので、かなり助かります。

スクリプトの説明

スクリプトの全体構成

前置きが非常に長くなりましたが、今回作成したスクリプトのディレクトリ構成は以下の通りです。

> tree
.
├── .gitignore
├── dst
├── package-lock.json
├── package.json
├── src
│   ├── index.ts
│   └── library
│       └── stsClient.ts
└── tsconfig.json

3 directories, 6 files

./src/配下のスクリプトをコンパイルした結果を、./dst/に出力するようにしています。

package.jsontsconfig.jsonは以下の通りです。

{
  "name": "create-ec2-instance",
  "version": "1.0.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "ts-node ./src/index.ts",
    "dev:watch": "ts-node-dev --respawn ./src/index.ts",
    "clean": "rimraf dist/*",
    "tsc": "tsc",
    "build": "npm-run-all clean tsc",
    "start": "node ."
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^16.4.1",
    "typescript": "^4.3.5"
  },
  "dependencies": {
    "@aws-sdk/client-ec2": "^3.23.0",
    "@aws-sdk/client-sts": "^3.23.0",
    "fs": "^0.0.1-security"
  }
}

tsconfig.json

{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Basic Options */
    // "incremental": true,                         /* Enable incremental compilation */
    "target": "ES2021",                                /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
    "module": "commonjs",                           /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
    "lib": ["ES2021"],                                   /* Specify library files to be included in the compilation. */
    // "allowJs": true,                             /* Allow javascript files to be compiled. */
    // "checkJs": true,                             /* Report errors in .js files. */
    // "jsx": "preserve",                           /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
    "declaration": true,                         /* Generates corresponding '.d.ts' file. */
    "declarationMap": true,                      /* Generates a sourcemap for each corresponding '.d.ts' file. */
    // "sourceMap": true,                           /* Generates corresponding '.map' file. */
    // "outFile": "./",                             /* Concatenate and emit output to single file. */
    "outDir": "./dst/",                              /* Redirect output structure to the directory. */
    "rootDir": "./src/",                             /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    // "composite": true,                           /* Enable project compilation */
    // "tsBuildInfoFile": "./",                     /* Specify file to store incremental compilation information */
    // "removeComments": true,                      /* Do not emit comments to output. */
    // "noEmit": true,                              /* Do not emit outputs. */
    // "importHelpers": true,                       /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,                  /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,                     /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true,                                 /* Enable all strict type-checking options. */
    "noImplicitAny": true,                       /* Raise error on expressions and declarations with an implied 'any' type. */
    "strictNullChecks": true,                    /* Enable strict null checks. */
    "strictFunctionTypes": true,                 /* Enable strict checking of function types. */
    "strictBindCallApply": true,                 /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
    "strictPropertyInitialization": true,        /* Enable strict checking of property initialization in classes. */
    "noImplicitThis": true,                      /* Raise error on 'this' expressions with an implied 'any' type. */
    "alwaysStrict": true,                        /* Parse in strict mode and emit "use strict" for each source file. */

    /* Additional Checks */
    "noUnusedLocals": true,                      /* Report errors on unused locals. */
    "noUnusedParameters": true,                  /* Report errors on unused parameters. */
    "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
    "noFallthroughCasesInSwitch": true,          /* Report errors for fallthrough cases in switch statement. */
    "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
    "noImplicitOverride": true,                  /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
    "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */

    /* Module Resolution Options */
    // "moduleResolution": "node",                  /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    // "baseUrl": "./",                             /* Base directory to resolve non-absolute module names. */
    // "paths": {},                                 /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
    // "rootDirs": [],                              /* List of root folders whose combined content represents the structure of the project at runtime. */
    "typeRoots": ["./node_modules/@types"],                             /* List of folders to include type definitions from. */
    // "types": [],                                 /* Type declaration files to be included in compilation. */
    // "allowSyntheticDefaultImports": true,        /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true,                        /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,                    /* Do not resolve the real path of symlinks. */
    // "allowUmdGlobalAccess": true,                /* Allow accessing UMD globals from modules. */

    /* Source Map Options */
    // "sourceRoot": "",                            /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                               /* Specify the location where debugger should locate map files instead of generated locations. */
    "inlineSourceMap": true,                     /* Emit a single file with source maps instead of having a separate file. */
    "inlineSources": true,                       /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    "experimentalDecorators": true,              /* Enables experimental support for ES7 decorators. */
    "emitDecoratorMetadata": true,               /* Enables experimental support for emitting type metadata for decorators. */

    /* Advanced Options */
    "skipLibCheck": true,                           /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true        /* Disallow inconsistently-cased references to the same file. */
  }
}

メインとなるスクリプトの説明

メインで実行するスクリプトは./src/index.tsになります。

処理の流れは以下の通りです。

  1. 引数で、スイッチロール先のIAMロールのプロファイル名と、リージョン名を指定する
    1. 引数に不足があれば、処理を終了する
  2. importした./src/library/stsClient.tsstsClient()を実行し、指定したプロファイル名で認証情報を取得する
  3. 取得した認証情報を設定し、EC2インスタンスを作成する
  4. 作成したEC2インスタンスの情報を表示する

認証情報を取得した後、わざわざ手動で環境変数に設定するような仕組みにしたくなかったので、スクリプトで全ての処理が完結するように書いてみました。

また、今回動作させるスクリプトは例として、3. 取得した認証情報を設定し、EC2インスタンスを作成するとしましたが、3.以降は任意の処理を記載していただければと思います。

実際のスクリプトは以下の通りです。

./src/index.ts

import {
  EC2Client,
  RunInstancesCommand,
  RunInstancesCommandInput,
  DescribeInstancesCommand,
} from "@aws-sdk/client-ec2";
import { stsClient } from "./library/stsClient";

const main = async () => {
  if (!process.argv[2] || !process.argv[3]) {
    console.log("Enter the profile name and region name as arguments.");
    console.log(`e.g. node ${process.argv[1]} non-97 us-east-1`);
    return;
  }

  // Set profile name
  const profileName: string = process.argv[2];

  // Set region name
  const regionName: string = process.argv[3];

  // Get Credntials
  const awsCredentials = await stsClient(profileName, regionName).catch(
    (error) => {
      console.error("Get credntials error!! \n\n", error);
    }
  );
  if (!awsCredentials) return;

  // Set EC2 Client
  const ec2Client = new EC2Client({
    credentials: awsCredentials.config.credentials,
    region: regionName,
  });

  // Create EC2 Instance (Amazon Linux 2)
  const runInstancesCommandInput: RunInstancesCommandInput = {
    ImageId: "ami-0dc2d3e4c0f9ebd18",
    InstanceType: "t3.micro",
    MinCount: 1,
    MaxCount: 1,
    TagSpecifications: [
      {
        ResourceType: "instance",
        Tags: [
          {
            Key: "Name",
            Value: "my-instance",
          },
          {
            Key: "System",
            Value: "SDK v3 test",
          },
        ],
      },
    ],
  };
  const runInstancesCommandOutput = await ec2Client
    .send(new RunInstancesCommand(runInstancesCommandInput))
    .catch((error) => {
      console.error("Create EC2 Instance error!! \n\n", error);
    });
  if (!runInstancesCommandOutput) return;

  const instanceId = <string>(
    runInstancesCommandOutput?.Instances?.[0]?.InstanceId
  );

  // Describe the EC2 Instance that was created.
  const describeInstancesCommandOutput = await ec2Client
    .send(new DescribeInstancesCommand({ InstanceIds: [instanceId] }))
    .then((instance) => {
      console.log(JSON.stringify(instance, null, 2));
      return instance;
    })
    .catch((error) => {
      console.error("Describe the EC2 Instance error!! \n\n", error);
    });

  return describeInstancesCommandOutput;
};

main();

AssumeRoleをするスクリプトの説明

それでは、実際のAssumeRole(スイッチロール)のキモになる、./src/library/stsClient.tsについて説明をします。

全体的な処理の流れは以下の通りです。(前回の記事と一緒です)

  1. 引数とした渡されたプロファイル名を元に、getRoleProfile()を呼び出して、スイッチロールするためのプロファイルの情報を取得する
    1. プロファイル情報が記載されている~/.aws/configを読み取る
    2. ~/.aws/configに指定したプロファイル名がなければ処理を終了する
    3. 指定したプロファイル名のMFAのシリアルナンバーとIAMロールのARNを取得し、戻り値として返却する
  2. 取得したプロファイル情報を元に、assumeRole()を呼び出して、一時的な認証情報を取得する
    1. readStandardInput()を呼び出し、利用者からMFAのトークンの入力を受け取る
    2. 入力されたMFAトークンとIAMロールのARNからAsuumeRole(スイッチロール)をして、一時的な認証情報を習得し、戻り値として返却する
  3. 取得した一時的な認証情報を呼び出し元に返却する

また、~/.aws/configに以下のように複数のプロファイルを登録されている状態でも対応できるようにしています。

~/.aws/config

[default]
region = us-east-1
output = yaml

[profile cm-non-97]
region = us-east-1
mfa_serial = arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名>
role_arn = arn:aws:iam::<スイッチロール先のAWSアカウントID>:role/cm-non-97
source_profile = default

[profile non-97]
region = us-east-1
mfa_serial = arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名>
role_arn = arn:aws:iam::<スイッチロール先のAWSアカウントID>:role/non-97
source_profile = default

実際の./src/library/stsClient.tsは以下の通りです。

./src/library/stsClient.ts

import {
  STSClient,
  AssumeRoleCommand,
  AssumeRoleCommandInput,
  AssumeRoleCommandOutput,
} from "@aws-sdk/client-sts";
import { createInterface } from "readline";
import * as fs from "fs";

interface RoleProfile {
  mfaSerial: string;
  roleArn: string;
}

// STS Client
export const stsClient = async (
  profileName: string,
  regionName: string
): Promise<STSClient> => {
  const roleProfile = await getRoleProfile(profileName);

  const credentials = await assumeRole(roleProfile, regionName);
  return new Promise((resolve, reject) => {
    if (credentials) {
      resolve(
        new STSClient({
          credentials: {
            accessKeyId: <string>credentials.AccessKeyId,
            secretAccessKey: <string>credentials.SecretAccessKey,
            sessionToken: credentials.SessionToken,
          },
        })
      );
    } else {
      reject("Failed assume role.");
    }
  });
};

// Get IAM Role profile
export const getRoleProfile = async (
  profileName: string
): Promise<RoleProfile> => {
  return new Promise((resolve, reject) => {
    // Read ~/.aws/config
    const awsConfig = <string>(() => {
      try {
        return fs.readFileSync(`${process.env["HOME"]}/.aws/config`, "utf8");
      } catch (error) {
        reject(error);
        return;
      }
    })();

    // Check if there is a matching IAM Role Profile
    const awsConfigLines = awsConfig.toString().split("\n");
    const profileIndex = awsConfigLines.indexOf(`[profile ${profileName}]`);
    if (profileIndex == -1) {
      reject(
        `${profileName} does not exist in "${process.env["HOME"]}/.aws/config".`
      );
    }

    // Get the index at the end of the specified IAM Role Profile
    const profileEndIndex = awsConfigLines.findIndex(
      (line: string, index: number) =>
        line === "" && index > profileIndex && index < profileIndex + 6
    );
    if (profileEndIndex == -1) {
      reject(
        `Add a new line at the end of ${profileName} in "${process.env["HOME"]}/.aws/config".`
      );
    }

    // Get MFA serial of the profile
    const mfaSerial = <string>(() => {
      const mfaSerialLine = awsConfigLines.find(
        (line: string, index: number) =>
          line.indexOf("mfa_serial") === 0 &&
          index > profileIndex &&
          index < profileEndIndex
      );
      if (!mfaSerialLine) {
        reject(`"mfa_serial" does not exist in ${profileName}.`);
        return;
      }
      const mfaSerialIndex = mfaSerialLine.indexOf("=") + 1;
      return mfaSerialLine.slice(mfaSerialIndex).trim();
    })();

    // Get IAM Role Arn of the profile
    const roleArn = <string>(() => {
      const roleArnlLine = awsConfigLines.find(
        (line: string, index: number) =>
          line.indexOf("role_arn") === 0 &&
          index > profileIndex &&
          index < profileEndIndex
      );
      if (!roleArnlLine) {
        reject(`"role_arn" does not exist in ${profileName}.`);
        return;
      }
      const roleArnIndex = roleArnlLine.indexOf("=") + 1;
      return roleArnlLine.slice(roleArnIndex).trim();
    })();

    resolve({
      mfaSerial: mfaSerial,
      roleArn: roleArn,
    });
  });
};

// Assume Role
export const assumeRole = async (
  roleProfile: RoleProfile,
  regionName: string
): Promise<AssumeRoleCommandOutput["Credentials"]> => {
  const stsClient = new STSClient({ region: regionName });

  // Read the MFA Token in standard input
  const mfaToken = await readStandardInput(
    `MFA token for ${roleProfile.mfaSerial} > `
  );

  const params: AssumeRoleCommandInput = {
    RoleArn: roleProfile.roleArn,
    SerialNumber: roleProfile.mfaSerial,
    TokenCode: <string>mfaToken,
    RoleSessionName: new Date().getTime().toString(),
    DurationSeconds: 900,
  };
  return new Promise((resolve, reject) => {
    stsClient.send(new AssumeRoleCommand(params)).then(
      (data) => {
        resolve(data.Credentials);
      },
      (error) => {
        reject(error);
      }
    );
  });
};

// Read standard input
export const readStandardInput = async (
  questionText: string
): Promise<string> => {
  const readline = createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  return new Promise((resolve, reject) => {
    readline.question(questionText, (answerText) => {
      if (answerText) {
        resolve(answerText);
      } else {
        reject("Failed read standard input.");
      }
      readline.close();
    });
  });
};

やってみた

それでは、実際にスクリプトを実行して、EC2インスタンスの作成及び、作成したEC2インスタンスの情報を取得してみます。また、失敗パターンとして、以下パターンも試してみました。

  1. 引数にスイッチロール先のプロファイル名とリージョン名を入力していない
  2. ~/.aws/configに存在しないプロファイル名を指定した
  3. MFAで無関係な値を入力した
  4. スイッチロール先のIAMロールにIAMポリシーをアタッチしなかった

実際の操作のログは以下の通りです。

TypeScriptのコンパイル

# TypeScriptのコンパイル
> npx tsc

# ./dst/ 配下にコンパイルした結果が出力されたか確認
> tree
.
├── .gitignore
├── dst
│   ├── index.d.ts
│   ├── index.d.ts.map
│   ├── index.js
│   └── library
│       ├── stsClient.d.ts
│       ├── stsClient.d.ts.map
│       └── stsClient.js
├── package-lock.json
├── package.json
├── src
│   ├── index.ts
│   └── library
│       └── stsClient.ts
└── tsconfig.json

4 directories, 12 files

失敗パターン1.引数にスイッチロール先のプロファイル名とリージョン名を入力していない

> node ./dst/index.js
Enter the profile name and region name as arguments.
e.g. node /<作業ディレクトリ名>/dst/index.js non-97 us-east-1

失敗パターン2. ~/.aws/configに存在しないプロファイル名を指定した

> node ./dst/index.js cm-non-971 us-east-1
Get credntials error!!

 cm-non-971 does not exist in "/<ホームディレクトリ名>/.aws/config".

失敗パターン3. MFAで無関係な値を入力した

> node ./dst/index.js cm-non-97 us-east-1
MFA token for arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名> > 1234567
Get credntials error!!

 Error [ValidationError]: 1 validation error detected: Value '1234567' at 'tokenCode' failed to satisfy constraint: Member must have length less than or equal to 6
    at deserializeAws_queryAssumeRoleCommandError (/<作業ディレクトリ名>/node_modules/@aws-sdk/client-sts/dist/cjs/protocols/Aws_query.js:181:41)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)
    at async /<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-serde/dist/cjs/deserializerMiddleware.js:6:20
    at async /<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-signing/dist/cjs/middleware.js:12:24
    at async StandardRetryStrategy.retry (/<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-retry/dist/cjs/StandardRetryStrategy.js:51:46)
    at async /<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-logger/dist/cjs/loggerMiddleware.js:6:22 {
  Type: 'Sender',
  Code: 'ValidationError',
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 400,
    requestId: 'fc18ebbe-01dc-4047-86b6-870b9627436e',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}

失敗パターン4. スイッチロール先のIAMロールにIAMポリシーをアタッチしなかった

> node ./dst/index.js cm-non-97 us-east-1
MFA token for arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名> > 354483
Create EC2 Instance error!!

 UnauthorizedOperation: You are not authorized to perform this operation. Encoded authorization failure message: yYF4g5vqSs_wE4YfujWy7bnGZbbJ1_0HWLg5TJ-GiMlofdO-1jeh2Z4J1RErz7N9AnI4-NtkrrEtayZnGaf9BdgqAzf4vuXP7288-40V5bIcaJYPvJdP1uwOYI7yMPKOFV6Bank8TjuSHHxniSb9d5qq-pF10_eERMn0aKMCudz_yho9uFcjo3OhMY7SOs2tAWik2CKguES5vlt-aKqpZzmck17XkKs-oKqzthDNN6PgeGbWn5_-4vwapl3lbMLW4CyVydQChpnIQHmgLusy_V88NpaQ5Jw_IBNnBGdNqYYSlZrmWvdoWuQjjsuMYBCXnbBoTGO6oKYEW7V5ykyXO4-NQ-ywBnBs7p3mpFGdpta1MdxUxm-ccuFJjh0cQAixRgoASBvkwhB5zdsPqyZBooGLtGv05cGZ_FlXMw-e-I98qSl0TRD9jIsqsVMT8h69n70sPeymjVYGvJ7whfhup9a3dl9l4FIVZmlJgG35th-c5j69DRj0EZClTxbUEpRhwNZen9Ow_5MPnvUVn4P1KtmlyFT9aO8r7mT9fK46AFy_ERu5lrNy_3g-Vjov6G2fw7Ptb6kql6tmxUgj0yb6mgJKcUQ4Xcv_OvHbtkpK4U3ebLJXQjdBnT079iBnJqq7oAYtoV-qPN-lVyc1CdnNy1v1rhNxIHsvjQTwACZ93-gZSuupt8ZS
    at deserializeAws_ec2RunInstancesCommandError (/<作業ディレクトリ名>/node_modules/@aws-sdk/client-ec2/dist/cjs/protocols/Aws_ec2.js:23380:41)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)
    at async /<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-serde/dist/cjs/deserializerMiddleware.js:6:20
    at async /<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-signing/dist/cjs/middleware.js:12:24
    at async StandardRetryStrategy.retry (/<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-retry/dist/cjs/StandardRetryStrategy.js:51:46)
    at async /<作業ディレクトリ名>/node_modules/@aws-sdk/middleware-logger/dist/cjs/loggerMiddleware.js:6:22
    at async main (/<作業ディレクトリ名>/dst/index.js:48:39) {
  Code: 'UnauthorizedOperation',
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 403,
    requestId: '4c56d985-3cda-40df-b282-89d0dda9c080',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}

成功パターン

> node ./dst/index.js cm-non-97 us-east-1 
MFA token for arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名> > 130871
{
  "$metadata": {
    "httpStatusCode": 200,
    "requestId": "1dcc7cce-604e-42b9-af14-4dc499bd72fb",
    "attempts": 1,
    "totalRetryDelay": 0
  },
  "Reservations": [
    {
      "Groups": [],
      "Instances": [
        {
          "AmiLaunchIndex": 0,
          "ImageId": "ami-0dc2d3e4c0f9ebd18",
          "InstanceId": "i-0dd6ecbcefabda192",
          "InstanceType": "t3.micro",
          "LaunchTime": "2021-08-01T02:46:59.000Z",
          "Monitoring": {
            "State": "disabled"
          },
          "Placement": {
            "AvailabilityZone": "us-east-1d",
            "GroupName": "",
            "Tenancy": "default"
          },
          "PrivateDnsName": "ip-172-31-31-224.ec2.internal",
          "PrivateIpAddress": "172.31.31.224",
          "ProductCodes": [],
          "PublicDnsName": "",
          "State": {
            "Code": 0,
            "Name": "pending"
          },
          "StateTransitionReason": "",
          "SubnetId": "subnet-01f3c5098eafd93e7",
          "VpcId": "vpc-0e0796981cea634c1",
          "Architecture": "x86_64",
          "BlockDeviceMappings": [],
          "ClientToken": "938fe32b-64eb-43b7-979f-0227b53e8b2c",
          "EbsOptimized": false,
          "EnaSupport": true,
          "Hypervisor": "xen",
          "NetworkInterfaces": [
            {
              "Attachment": {
                "AttachTime": "2021-08-01T02:46:59.000Z",
                "AttachmentId": "eni-attach-0e0a65ce9740cc962",
                "DeleteOnTermination": true,
                "DeviceIndex": 0,
                "Status": "attaching",
                "NetworkCardIndex": 0
              },
              "Description": "",
              "Groups": [
                {
                  "GroupName": "default",
                  "GroupId": "sg-09833fa43dc030900"
                }
              ],
              "Ipv6Addresses": [],
              "MacAddress": "0a:10:6f:c7:61:37",
              "NetworkInterfaceId": "eni-09cd3af00db261ef9",
              "OwnerId": "<AWSアカウントID>",
              "PrivateDnsName": "ip-172-31-31-224.ec2.internal",
              "PrivateIpAddress": "172.31.31.224",
              "PrivateIpAddresses": [
                {
                  "Primary": true,
                  "PrivateDnsName": "ip-172-31-31-224.ec2.internal",
                  "PrivateIpAddress": "172.31.31.224"
                }
              ],
              "SourceDestCheck": true,
              "Status": "in-use",
              "SubnetId": "subnet-01f3c5098eafd93e7",
              "VpcId": "vpc-0e0796981cea634c1",
              "InterfaceType": "interface"
            }
          ],
          "RootDeviceName": "/dev/xvda",
          "RootDeviceType": "ebs",
          "SecurityGroups": [
            {
              "GroupName": "default",
              "GroupId": "sg-09833fa43dc030900"
            }
          ],
          "SourceDestCheck": true,
          "Tags": [
            {
              "Key": "System",
              "Value": "SDK v3 test"
            },
            {
              "Key": "Name",
              "Value": "my-instance"
            }
          ],
          "VirtualizationType": "hvm",
          "CpuOptions": {
            "CoreCount": 1,
            "ThreadsPerCore": 2
          },
          "CapacityReservationSpecification": {
            "CapacityReservationPreference": "open"
          },
          "HibernationOptions": {
            "Configured": false
          },
          "MetadataOptions": {
            "State": "pending",
            "HttpTokens": "optional",
            "HttpPutResponseHopLimit": 1,
            "HttpEndpoint": "enabled"
          },
          "EnclaveOptions": {
            "Enabled": false
          }
        }
      ],
      "OwnerId": "<AWSアカウントID>",
      "ReservationId": "r-077f138be4447a179"
    }
  ]
}

それぞれ意図した通りの動作をしており、成功パターンのみ正常にEC2インスタンスの作成及び、作成したEC2インスタンスの情報を表示できていますね。やったね。

慣れは必要かもしれないけど書きやすい

AWS SDK for JavaScript v3の場合、何かAPIを実行する際はec2Client.send()というようにsendメソッドを使う必要があり、少し慣れが必要です。 ただ、sendメソッドというワンクッションが増えただけなので、慣れるまでにそこまで時間はかからないと思います。

それを差し引いても、SDK自身がTypeScriptで書かれていることによるメリットの方が大きく、今後はv3で書いていこうと思いました。個人的には自分でインターフェースやクラスを定義する場面が少なくなりそうで嬉しいです。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!