ねぎ嫌い

思いついたことをてきとうに。

GoogleApisライブラリに実装されているExponential Backoff

動機

Google Admin SDKを使って遊んでる時にふと気になって読んだので残しておく。
調査の経過はZennのScrapにしてある。

GoogleApisライブラリのExponential Backoffを追いたい

結論

googleapisが依存しているgaxios(Axiosのラッパー)の中で実装されている。
retry.tsでリトライに関する判定等を行なっており、内部で再施行回数によって指数的に待ち時間を挿入している。
実際にbackoffを計算しているところを抜粋すると以下

  // Formula: retryDelay + ((2^c - 1 / 2) * 1000)
  const delay =
    retryDelay + ((Math.pow(2, config.currentRetryAttempt) - 1) / 2) * 1000;

ref. https://github.com/googleapis/gaxios/blob/v6.5.0/src/retry.ts#L67-L69

Exponential Backoffの実装で見かける(気がする)Jitterに関しては考慮されていなかった。

詳細

googleapisライブラリから実際にリクエストを投げるまでの流れは以下のライブラリを経由して実行されている。
googleapis > googleapis-common > google-auth-library > gaxios

googleapis

googleapisが担っている部分はI/Fの提供と後続のAdapter/Bridge的な役割に見える。
実際 Admin APIの中身を見ていると処理の大半がprotobufから生成したと思われるI/Fの定義とちょっとしたブリッジのためのコードしかない。
処理の実態は createApiRequest なる関数で行っていることがわかる。
createApiRequest 自体は googleapis-common で実装されている。

googleapis-common

googleapis-commonが担っているのは認証とAPIコールの橋渡しに見える。
上述した createApiRequest の実体を読んでいると、認証を要求したり、認証と一体化してるgoogle-auth-libraryを呼び出している。

google-auth-library

ここでは認証情報が付与されていなかったらOAuthなりService Accountなり経由でトークンを発行し、そのトークンを用いてGaxios経由でAPIコールするようなミドルウェアに見える。
例えばここら辺を見ると、一度APIコールして認証系のエラーが出たらリフレッシュトークンを再取得して再実行するような作りをしている。
ここらへんを眺めてるとどういう認証方式があるのか、それぞれでどう動いているのかがコードベースでわかるので認証で詰まったら読んでおきたい。

gaxios

前述の通り、AxiosのラッパーでありHTTPリクエスト・レスポンス周りに対して責務を持っているように見える。
multipartなリクエストを投げる時にGeneratorを使ってたりするのでどっかでちゃんと理解したい。

おわり

たまにしか読まないけどライブラリを読むのも勉強になっていいね。

serverless framework x localstackでSNSトリガーなLambdaを動かす

動機

  • serverless frameworkを使ってSNSトリガーで動作する関数の実装をしていたがローカルで動作確認したくなった
  • serverless-offilne-snsを使っても良いのだけれど、リソースが増えるたびにプラグインを増やしたくなかった

環境

  • Mac OS 14.3
  • Docker v24.0.6
  • node v16.15.0
  • serverless framework v3.22.0
  • LocalStack v3.1 (PRO版未購入です... :bow:)

構成

  • LocalStackはdocker composeで別プロセスで管理し、serverless frameworkからLambdaをDeployする
  • IaCはTerraformを使っているのでSNS等のリソースはLocalStackの起動時スクリプトで生成し、serverless frameworkからは今回作らない

結論

以下のリポジトリで表現されている通り

https://github.com/anizozina/sls_localstack

多少の解説

LocalStackへのリソースの生成

前述した通り一部のリソースはTerraformで生成しているのでserverless framework経由で作らないようにしていた。
./localstack/init-aws.sh に記載の通り明示的にリソースを生成させている。

docker-compose.ymlのvolumesで指定している通り、 /etc/localstack/init/ 以下のパスに実行可能なスクリプトを置いておけば実行してくれる。
lifecycleに応じて走らせるスクリプトを変えたい場合は以下を参照のこと。

docs.localstack.cloud

Deploy

今回はAWSへのDeployをしないので端折りまくっているが、serverless.tsのcustom以下の設定でLocalStackを向き先にするステージを指定している。
defaultStageをlocalにしており、LocalStackを向いているステージにlocalを指定しているため今回のDeployはLocalStackにされたということで、別途ステージを用意する場合は注意が必要。

なんか詰まったところ

やたらDeployが止まる

元々利用するServiceはlambdaとsnsくらいしかないと思っていたので、SERVICESの指定をかなり絞っていた。
serverless frameworkが内部でCloudFormationのStackを作るし、IAMもS3も必要になってくるので結果的に有効化したSERVICESはそれなりの量になった…。

API for service 'lambda' not yet implemented or pro feature

Deploy時に以下のようなエラーが出て動かなかった。

API for service 'lambda' not yet implemented or pro feature

つぎはぎのdocker-compose.ymlを作っていたので当然っちゃ当然なんだけど、全然原因が掴めなかった。。 結論を言うと、参考にしていたファイルがLocalStack v1時代に作られたものだったっぽく、公式に記載の通りのマイグレーションが必要になった。

    environment:
      DEBUG: 1
+      DOCKER_HOST: unix:///var/run/docker.sock
      LS_LOG: trace
      SERVICES: 'cloudformation,lambda,logs,iam,s3,sns,events'
      AWS_ACCESS_KEY_ID: 'localstack-test'
      AWS_SECRET_ACCESS_KEY: 'localstack-test'
      AWS_DEFAULT_REGION: 'ap-northeast-1'
-      PROVIDER_OVERRIDE_LAMBDA: docker-reuse
+      LAMBDA_EXECUTOR: 'docker'
    volumes:
      - ./localstack/init-aws.sh:/etc/localstack/init/ready.d/init-aws.sh
+      - /var/run/docker.sock:/var/run/docker.sock

docs.localstack.cloud

ログがLocalStackのコンテナ内にしか出てなくて時間を浪費しまくってしまった。

SSMの読み込み

今回の構成には入れてないが、詰まったので備忘として残しておく。
たとえばStripeのシークレットキー等をDeploy時に環境変数に持たせたりしたいケースがある。
そのようなケースでは事前にParameter StoreやSecret Managerに入れておくことで露出を可能な限り減らす仕組みを構築していることがある。

例に漏れずそのような構成を取っていたがどうもSecret Managerの読み込みに失敗しまくるので回避策を。

  custom: {
    secrets: '${ssm:/aws/reference/secretsmanager/path_to_parameter}',
  }

こんな感じで指定していることがあると思うが、このままDeployするとssmへのアクセスに失敗してLocalStackへのDeployがこける。
もちろん事前にLocalStackのSecret Managerにはpath_to_parameterは用意しているし、ホストからそのパスでアクセスはできる。

で、最終的な回避策としては、/aws/reference/secretsmanager を取り除くとDeployできるようになった

  custom: {
-     secrets: '${ssm:/aws/reference/secretsmanager/path_to_parameter}',
+    secrets: '${ssm:path_to_parameter}',
  }

なのでちゃんとやろうとすると serverless-plugin-ifelse なんかを入れてStageに応じてParameterへのパスを変えたりしないとダメかも。

rrule.jsでJSTを扱いたい!!

背景

前回の記事Google Calendarの繰り返し予定の扱いについて書いたが、これを解釈してDBに突っ込む必要があった。
rruleはRFCで定められた仕様なのでライセンスが許せば、OSSのライブラリを活用できるはずで、実際実装してくれている有志がいる。
ref. GitHub - jakubroztocil/rrule: JavaScript library for working with recurrence rules for calendar dates as defined in the iCalendar RFC and more.

今回の困りごととしては、曜日の指定で、若干1日ズレるような挙動に遭遇した。
e.g. 毎週金曜日、の指定をしているはずなのに、結果を見てみると毎週土曜日になっているような挙動。

結論から言うと、Timezoneの扱いを考慮してあげる必要があった。
いくつかのIssueで言及されている通りTimezoneの扱いが直感的ではなく苦しんだので、回避策を紹介しておく。

TL;DR;

rrule.jsは内部的にUTCですべての日付を扱うので、日本時間の金曜日として処理しているつもりが、UTCの金曜日として扱われズレた。
内部的な処理としてはUTCとして実施し、データを利用する際にJSTに無理やり変換をすれば良い。

ただ、綺麗な回避策ではないので、他に案があればお伺いしたい所存...。

課題の詳細

そもそも発端となったGoogle Calendarからのインプットを見てみると、扱いたいデータは以下のようになっている。

{
  "start": {
      "dateTime": "2023-05-13T03:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-13T04:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230601T145959Z;BYDAY=SA"
  ]
}

これは2023-05-13の午前3時から4時までの予定を2023-06-01までの毎週土曜日に作成する条件になっている。
rrule.jsにこのrruleを食わせ、実際に予定として作成されるであろう日付を取得してみる。

import { rrulestr } from 'rrule';

const rrule = 'RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230601T145959Z;BYDAY=SA';
const dtstart = new Date('2023-05-13T03:00:00+09:00');

const rule = rrulestr(rrule, {
  dtstart: dtstart,
});
// 余談だが、`every week on Saturday until June 1, 2023` と出力してくれるので理解促進に役立つ
console.log(rule.toText());

console.log(rule.all())

結果としては、以下の出力が得られる

[
  2023-05-13T18:00:00.000Z,
  2023-05-20T18:00:00.000Z,
  2023-05-27T18:00:00.000Z
]

JSTに直すと、05-14(日)の午前3時からの予定になってしまうのがわかる。
本来欲しい出力の先頭は 2023-05-12T18:00:00.000Z2023-05-13T03:00:00.000+09:00 のいずれか。

このままだと意図しない挙動(=バグ)になってしまう…。

回避策

以下のような関数を作ってtimezoneのoffsetをごにょごにょしてあげることで回避した。

export const rruleToDates = (rrule: string, dtstart: string) => {
  const { date, offset } = trimOffset(dtstart);

  const events = rrulestr(rrule, {
    dtstart: date,
  });
  const convert = (d: Date) => new Date(d.getTime() + offset * 1000 * 60);
  return events.all().map(convert)
};

const trimOffset = (dateStr: string) => {
  const origin = new Date(dateStr);
  const offset = origin.getTimezoneOffset();
  const date = new Date(origin.getTime() + offset * -1 * 1000 * 60);
  return { date, offset };
};

テストコードの一部

    it('2023-05-20から毎週土曜日を3回分', () => {
      const rule = 'RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=SA';

      const startDate = '2023-05-20T18:00:00+09:00';
      const { dates: result } = rruleToDates(rule, startDate);
      expect(result).toHaveLength(3);
      expect(result[0].getTime()).toEqual(
        new Date('2023-05-20T18:00:00+09:00').getTime(),
      );
      expect(result[1].getTime()).toEqual(
        new Date('2023-05-27T18:00:00+09:00').getTime(),
      );
      expect(result[2].getTime()).toEqual(
        new Date('2023-06-03T18:00:00+09:00').getTime(),
      );
    });

懸念事項

一応このままでもほとんどのユースケースは動く。
あと考慮しないといけないのは、以下らへんかなと。

  • UNTIL/COUNTのいずれもない、無期限の予定に対するハンドリング
  • 終了日付の生成

前者に関しては、アプリの特性を考慮した上で、件数か日付での制約をall()の第一引数で実装してあげれば良さそう。
ref. https://github.com/jakubroztocil/rrule#rruleprototypealliterator

後者はGoogle Calendarから入ってくるstartDateTimeとendDateTimeの差を使ってあげれば良い。

Google Calendarの繰り返し予定を理解した

背景

Google Calendarと相互にデータの送受信を行うアプリケーションを実装しており、繰り返し予定の扱いが非常に難解だった。
パッと言葉だけ見てもあんまり理解できなかったので動かしながらメモした中身を残しておこうと思う。

前提

Google Calendarからのデータ取得は、通知チャンネルで受け取ったtokenからevents.listを叩いている。

ref. リソース変更の通知  |  Google Drive  |  Google Developers
ref. Events: list  |  Google Calendar  |  Google Developers

Google Calendarの繰り返し予定は、rruleという仕様に則っている。
rruleの仕様については以下のブログが非常に詳しい。

ref. RRULEに関するメモ書き - 前人未踏の領域へ WEB・インフラ・プログラミング全般編

お断り

データ構造っぽいものに整理しているが便宜上のものであることに留意。
Google Calendar側のデータ構造は全く分からない。

シナリオと発行されるイベント

繰り返し予定の作成

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "kind": "calendar#event",
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(終了日付指定)",
  "start": {
      "dateTime": "2023-05-13T03:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-13T04:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230701T145959Z;BYDAY=SA"
  ],
  "sequence": 0,
  "eventType": "default"
}

具体的な予定日の情報は含まれていないが、内部的には図のような構造をとっているように見える。
後述する通り、特定の予定の変更をする時は、ここで入ってくるidにsuffixを付与したものがidとして割り当てられている。

e.g. id = 3d6judg0t6ureeq1b4o8inkfd1_20230513T030000Z

ここでは2023-07-01まで毎週土曜日を繰り返し条件に指定している。

特定予約の変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、4月28日は連休前なので1時間早めたいケース。

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T200000Z",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-17T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-17T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurringEventId": "3d6judg0t6ureeq1b4o8inkfd1",
  "originalStartTime": {
      "dateTime": "2023-05-17T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "sequence": 1,
  "eventType": "default"
}

前述した通り、idは元の繰り返し予定のIDにsuffixがつく形式になっている。
見えている通り、UTCをsuffixとして生成している。

変更前後が観測できることもわかる。

特定日付の予定の削除

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、5月5日が子供の日なのでカレンダーから削除するようなケース。

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "kind": "calendar#event",
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T190000Z",
  "status": "cancelled",
  "recurringEventId": "3d6judg0t6ureeq1b4o8inkfd1",
  "originalStartTime": {
      "dateTime": "2023-05-17T04:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  }
}

特定日付の変更と同じように、suffixのついたIDに対して削除処理が走るのが見て取れる

繰り返し予定の削除

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "kind": "calendar#event",
  "etag": "\"3367880984644000\"",
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "cancelled"
}

繰り返し予定のIDのみを指定して削除が走る
おそらく内部的には同一のprefixを持つ実イベントに対する削除も走ってるんじゃないかと想定。

日付以降の予定の変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、5月から11時に変更するようなユースケース

概念図

作成されるイベントの構造(不要な属性は削除)
1.繰り返し条件の短縮

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-15T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-15T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230528T145959Z;BYDAY=SA"
  ],
  "sequence": 0,
  "eventType": "default"
}

見えている通り、繰り返し条件が2023-05-29までの毎週土曜日になっている。
元は前述の通り、07-01まで。
ここで重要なのはidが変更されていないこと。

2.繰り返し条件の作成

{
  "id": "rfr872ajjahljsp9807hmbrt82",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-29T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-29T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=SA"
  ],
  "sequence": 1,
  "eventType": "default"
}

startTimeに変更を発生させた日時が入り、3回分の予定について作成されている
(UNTILじゃなくてCOUNTなんだーとは思う。)
ここで id が採番され全く別のイベントとして扱われるようになっていることに留意。

つまり、元の繰り返し予約(ID=3d6judg0t6ureeq1b4o8inkfd1) を繰り返し条件ごと予定から削除してもこっちは残るということ。

特定予約変更後、すべての繰り返し条件変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、予定の重複があり、4月14日は9時にした。
そのあとで、メンバの都合が合わなくなったから、繰り返し条件を全て11時に変更するようなユースケース
あんまりやってほしくないけど現実的にはありうる。

概念図

作成されるイベントの構造(不要な属性は削除)
1.繰り返し条件の変更

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-15T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-15T08:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230528T145959Z;BYDAY=SA"
  ],
  "sequence": 1,
  "eventType": "default"
}

ここは特に違和感なく、rruleの条件変更が行われる。
idはそのまま引き継いでいる。

2.変更した特定日付の予定の削除

{
  "kind": "calendar#event",
  "etag": "\"3367885310606000\"",
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T200000Z",
  "status": "cancelled"
}

おそらくGoogleの内部では変更された特定日付のデータは独立したものとして扱われているのだと思われる。

特定日付の予定を変更後、別日以降の繰り返し条件の変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、予定の重複があり、4月14日は9時にした。
そのあとで、メンバの都合が合わなくなったから、5月からの繰り返し条件を全て11時に変更するようなユースケース

概念図

作成されるイベントの構造(不要な属性は削除)
1.繰り返し条件の変更

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-15T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-15T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230528T145959Z;BYDAY=SA"
  ],
  "sequence": 0,
  "eventType": "default"
}

2.変更した予定の修正

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T200000Z",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-17T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-17T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurringEventId": "3d6judg0t6ureeq1b4o8inkfd1",
  "originalStartTime": {
      "dateTime": "2023-05-17T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "sequence": 1,
  "eventType": "default"
}

3.以降の繰り返し条件の作成

{
  "id": "rfr872ajjahljsp9807hmbrt82",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-29T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-29T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=FR,MO,TH,TU,WE"
  ],
  "sequence": 1,
  "eventType": "default"
}

一番複雑なケースだと思われる。
ただ、ここまで見てればなんとなく言いたいことはわかる。

凡例

終わりに

Googleカレンダーの知らなかった仕様なんかも見られたのでなかなか面白かった。
ここで発生しているデータをどういう構造で扱うべきかまだ見えてないけど。

rrule.jsでJSTを扱うのに困ったのでこれも記事にしておきたい。

BitBucket PipelineでAWS Organizationsの各アカウントに対応したterraformの構築がしたい!

背景

  • CI/CDのツールとしてBitBucket Pipelineを使う必要があった
  • AWS Organizationsにより環境ごとにAWSアカウントが分かれていた

方針

  • 可能な限り余計な情報を持ちたくないので、OIDCを用いた仕組みを構築したい
  • 手元での実行もありうるが変にロックを掛け合いたくないので、terraformのstate/lockはリモートで管理したい

まずはOIDCを組むために、AWSアカウントと何かしらを紐づけて管理する必要がある。
今回はDeploymentsの1つの設定とAWSアカウントが紐づく形をとることで解決を図る。

2つ目はterraformを実行するために必要な設定となるのでAWS CLIを用いて作成する。
これ自体は別に都度回すようなものでもないので、手作業。

実装

1. BitBucket PipelineとAWSでOIDCができるようにする

参考:
Bitbucket Pipelines OpenID Connect を使用して AWS にデプロイする | Bitbucket Cloud | Atlassian サポート

基本的には上記公式ドキュメントを参考にして設定をしてあげれば良い。
前提として、設定を行う際にはリポジトリの設定を行える権限を持っている必要がある。
これがないとリポジトリごとの"プロバイダーURL"を取得できない。

a. リポジトリのプロバイダーURLとAudience等必要な値を取得しておく

リポジトリの設定画面から、OpenID Connectの設定画面を開く。
IDプロバイダの設定に必要なIdentity provider URLAudienceの値を取得しておく。

repository setting

さらに、後続で必要となる環境ごとの識別に必要なDeployments EnvironmentのUUIDを拾っておく。
ここではTestのEnvironmentに対して20af...のUUIDが発行されている。

Uniquer identifiersのexample payloadにある通りのリクエストがAWSに送られ、AWSはそれを検証するイメージ。
なのでAWS側にどの値を許可するのかを設定してあげる必要がある。

b. IDプロバイダの設定を行う

terraformの連携先となるAWSのアカウントにログインし、IAMからIDプロバイダの設定を行う。
基本的には公式ドキュメントに書いてある通りなので引っかかりそうなポイントだけ記載する。
この画面での完成形は下図のようになる。

IDプロバイダの設定

  • プロバイダを追加する際は、OpenID Connectを選択する
  • プロバイダのURLはリポジトリごとに払い出されるURLを指定する
    • 念の為サムプリントを取得して、疎通を確認しておく
  • 対象者には、Audienceの値を入力する
    • わかりやすい名前で、と思って適当な名前入れたらIAM Roleの検証で詰まって時間溶かしたので要注意...

c. IAM Roleの作成

ここで設定したIAM Roleを使ってterraformはアクセスをする。
ので、紐づけるIAM Policyは作成したいリソースに限ったものを付与する。
ここでは面倒なのでAdministratorの権限をつけてしまうが、ちゃんとやるなら最小権限の原則に従う。

ドキュメント記載の通り、エンティティタイプはウェブアイデンティティを選択すること。
選択すると、上で作成したbitbucketのリポジトリをIDプロバイダとして選択できるようになる。
また、IDプロバイダを選択するとAudienceも選択できる。

(ここでは)ポリシーにはAdministratorAccessを選択する。

ロールの名前や説明は適当にやっておく。
信頼されたエンティティを選択するでは前述したDeploymentsを指定してそれ以外の環境からのアクセスは弾くようにしておきたい。
なので、以下のようにConditionsを変更する

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Principal": {
                "Federated": "arn:aws:iam::xxxxxxxxxxxx:oidc-provider/api.bitbucket.org/2.0/workspaces/xxxxxxxxxx/pipelines-config/identity/oidc"
            },
            "Condition": {
-                "StringEquals": {
-                    "api.bitbucket.org/2.0/workspaces/xxxxxxxxxx/pipelines-config/identity/oidc:aud": [
-                        "ari:cloud:bitbucket::workspace/2ab1d73d-ec10-4934-8233-xxxxxxxxxxxx"
-                    ]
-                }
+               "StringLike": {
+                    "api.bitbucket.org/2.0/workspaces/xxxxxxxxxx/pipelines-config/identity/oidc:sub": "{b0ef120b-014d-4b4f-96c8-xxxxxxxxxxxx}:{20af05a4-cc1b-4e45-ad22-xxxxxxxxxxxx}:*"
+                }
            }
        }
    ]
}

元々はAudienceが一致しているかを見ていたが、今回はDeploymentsまで含めて見たいのでsubjectの値を見る。
公式ドキュメントで言えば ここらへん で言及されている話題になる。

Roleの作成ができたら一旦完了。 これを環境分繰り返しておく。もちろん、環境ごとにDeployments のUUIDを変えること。

d. とりあえず動作して確認する

一旦動かして確認するのであれば以下のようなbitbucket pipelineを用意して確認するとよさそう。

image: atlassian/pipelines-awscli
pipelines:
  branches:
    test:
      - step:
          name: s3 ls on test
          deployment: test
          oidc: true
          script:
            - export AWS_REGION=ap-northeast-1
            - export AWS_ROLE_ARN=arn:aws:iam::xxxxxxxx:role/BitbucketPipelineForTesting
            - export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
            - echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
            - unset AWS_SECRET_ACCESS_KEY
            - unset AWS_ACCESS_KEY_ID
            - aws s3 ls
    staging:
      - step:
          name: s3 ls on staging
          deployment: staging
          oidc: true
          script:
            - export AWS_REGION=ap-northeast-1
            - export AWS_ROLE_ARN=arn:aws:iam::yyyyyyyy:role/BitbucketPipelineForTesting
            - export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
            - echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
            - unset AWS_SECRET_ACCESS_KEY
            - unset AWS_ACCESS_KEY_ID
            - aws s3 ls

重要なのは、AWS_ACCESS_KEY_IDを一度unsetしておくこと。
これがあるとどちらも同じアカウントで動作してしまう。たぶんAWS CLIの内部でAWS_ACCESS_KEY_IDが優先されているだけだと思うんだけど。
今回は AWS_WEB_IDENTITY_TOKEN_FILE が指定されているのでそちらをベースに動く。
terraformも同様の動作をする。

もしかしたら共通のステップを用意して、AWS_ROLE_ARNをdeploymentのvariableに指定すればもっと綺麗になるかもしれないがご容赦いただければと。

2. terraform用に必要なリソースを準備する

方針に記載の通り、リモートでstate/lockを管理したい。
stateはS3で、lockはdynamodbで管理するのが一般的っぽいのでそちらを。

a. S3 バケットの作成

特に複雑に考えず、ap-northeast-1にバケットを作成し、public accessをブロックし、バージョニングを有効にしているだけ。

export BUCKET_NAME=tekitouna-tfstate-dev
aws s3api create-bucket --bucket $BUCKET_NAME --region ap-northeast-1 --create-bucket-configuration LocationConstraint="ap-northeast-1"

aws s3api put-public-access-block --region ap-northeast-1 --bucket $BUCKET_NAME --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

aws s3api put-bucket-versioning --region ap-northeast-1 --bucket $BUCKET_NAME --versioning-configuration Status=Enabled

b. dynamodbのテーブル作成

export TABLE_NAME=tekitouna-tflock-dev
aws dynamodb create-table \
  --attribute-definitions "AttributeName=LockID,AttributeType=S"\
  --table-name "${TABLE_NAME}" \
  --key-schema  "AttributeName=LockID,KeyType=HASH" \
  --billing-mode PAY_PER_REQUEST

LockIDの指定はterraform側の仕様なのでそれに従っている
ref. Backend Type: s3 | Terraform | HashiCorp Developer

c. ローカルから接続できるか確認する

とりあえずbackend configはファイル指定の方が楽だと思うので以下のようなファイルを作って指定させる

./env/dev.tfbackend

bucket = "tekitouna-tfstate-dev"
key    = "dev.tfstate"
region = "ap-northeast-1"
dynamodb_table = "tekitouna-tflock-dev"

./provider.tf

terraform {
  backend "s3" {
  }
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.70.0"
    }
  }
}

terraform init -reconfigure -backend-config ./env/dev.tfbackend で動くことを確認

3. BitBucket Pipelineに実装する

あとは先ほど作成したテスト用のbitbucket pipelineをいじっていい感じにすればOK

image: hashicorp/terraform:1.4
pipelines:
  branches:
    test:
      - step:
          name: terraform-plan on test
          deployment: test
          oidc: true
          script:
            - export AWS_REGION=ap-northeast-1
            - export AWS_ROLE_ARN=arn:aws:iam::xxxxxxxx:role/BitbucketPipelineForTesting
            - export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
            - echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
            - unset AWS_SECRET_ACCESS_KEY
            - unset AWS_ACCESS_KEY_ID
            - terraform init -input=false -backend-config=env/dev.tfbackend -reconfigure
            - terraform plan -input=false -var-file=env/dev.tfvars
    staging:
      - step:
          name: terraform-plan on staging
          deployment: staging
          oidc: true
          script:
            - export AWS_REGION=ap-northeast-1
            - export AWS_ROLE_ARN=arn:aws:iam::yyyyyyyy:role/BitbucketPipelineForTesting
            - export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
            - echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
            - unset AWS_SECRET_ACCESS_KEY
            - unset AWS_ACCESS_KEY_ID
          - terraform init -input=false -backend-config=env/dev.tfbackend -reconfigure
              - terraform plan -input=false -var-file=env/dev.tfvars