コンシューマ駆動契約テストに入門してみた〜can-i-deploy編〜

タム

2024.10.29

38

こんにちは、タムです。

今回は、コンシューマ駆動の第4回目の投稿です。


第2回目の投稿でコンシューマ駆動契約テストをCI/CDに載せる取り組みを行いましたが、

最後に課題として挙げたものを覚えていますか?

覚えていないですよね、ていうか、そもそも読んでないですか。すみません。

先にざっと目を通していただけると幸いです。


コンシューマ側で契約を更新した際に、コンシューマ側のテストが成功したとしても

プロバイダ側が新しい契約をサポートしていなかった場合、

破壊的な変更がそのままデプロイされてしまうという問題です。

で、今回はその課題の解消を行いました。

それがタイトルにある、can-i-deploy (デプロイできますか?) です。

record-deployment

これまでの話の流れが追えていればcan-i-deployの仕組みはなんとなく想像がつくと思います。

基本的な仕組みとしては想像通りで、コンシューマが契約を更新後に、更新された契約を

引き続きプロバイダ側が満たしているかをブローカー経由でチェックしに行きます。


注意すべき点は、コンシューマがデプロイ可能かどうかを判断するためには、

現在のプロバイダのデプロイバージョンに対して確認する必要があるということです。

かつ、本番環境・開発環境など、一般的に複数のデプロイ環境があります。

デプロイしようとしている環境が開発環境であれば、開発環境にデプロイされている

プロバイダのバージョンに確認する必要があります。


そうなってくると、Pactブローカーに現在どのバージョンがどの環境にデプロイされているかという情報を予め教えてあげる必要があります。

これを、record-deploymentといいます。


というわけで、can-i-deployの前準備として、一旦現在デプロイされているコンシューマ・プロバイダに対して手動でrecord-deploymentを実行してみました。


~/work/auth0-next-custom$ docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant auth0-next-custom --version 445fec738b5a4761bda6379ca37286062d079401 --environment dev --broker-base-url http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com --broker-username xxxxxxxxxxxx --broker-password xxxxxxxxxxxx

No environment found with name 'dev'. Available options: production, test

~/work/auth0-next-custom$ 


dev環境がないと言われました。

production, testはそのまま使えるようですが、それ以外はまず環境自体を定義してあげる必要があるようです。

対応するアクションは、 create_environment です。


~/work/auth0-next-custom$ docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker create-environment --name dev --broker-base-url http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com --broker-username xxxxxxxxxxxx --broker-password xxxxxxxxxxxx

Created dev environment in the Pact Broker with UUID xxxxxxxxxxxx

~/work/auth0-next-custom


無事作成できました。それでは再度record-deploymentを実行してみましょう。


# コンシューマ

~/work/auth0-next-custom$ docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant auth0-next-custom --version 445fec738b5a4761bda6379ca37286062d079401 --environment dev --broker-base-url http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com --broker-username xxxxxxxxxxxx --broker-password xxxxxxxxxxxx

Recorded deployment of auth0-next-custom version 445fec738b5a4761bda6379ca37286062d079401 to dev environment in the Pact Broker.

~/work/auth0-next-custom# プロバイダ

~/work/auth0-next-custom$ docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant google-login-back --version f042bc78690b272fed7ab6735bcacd4a39bccf15 --environment dev --broker-base-url http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com --broker-username xxxxxxxxxxxx --broker-password xxxxxxxxxxxx

Recorded deployment of google-login-back version f042bc78690b272fed7ab6735bcacd4a39bccf15 to dev environment in the Pact Broker.

~/work/auth0-next-custom


今度は成功し、コンシューマとプロバイダの両方のデプロイバージョンをブローカーに記録できました。

record-deploymentした後の"Pact Matrix" (コンシューマ x プロバイダのバージョン一覧表) は以下のようになりました。

緑タグでデプロイ環境が示されていますね。



ローカル環境でcan-i-deployしてみる

それでは試しに、ローカル環境でcan-i-deployを実行してみましょう。


# コンシューマ

~/work/auth0-next-custom$ docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant auth0-next-custom --version 445fec738b5a4761bda6379ca37286062d079401 --to-environment dev --broker-base-url http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com --broker-username xxxxxxxxxxxx --broker-password xxxxxxxxxxxx

Computer says yes \o/ 

CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|------------|----------|--------
auth0-next-custom | 445fec7... | google-login-back | f042bc7... | true   | 1    

VERIFICATION RESULTS
--------------------
1. http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com/pacts/provider/google-login-back/consumer/auth0-next-custom/pact-version/611750a58c1c1b4697e602671bf23021bbe60df7/metadata/Y3ZuPTQ0NWZlYzczOGI1YTQ3NjFiZGE2Mzc5Y2EzNzI4NjA2MmQwNzk0MDE/verification-results/319 (success)

All required verification results are published and successful
~/work/auth0-next-custom# プロバイダ

~/work/auth0-next-custom$ docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant google-login-back --version f042bc78690b272fed7ab6735bcacd4a39bccf15 --to-environment dev --broker-base-url http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com --broker-username xxxxxxxxxxxx --broker-password xxxxxxxxxxxx

Computer says yes \o/ 

CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|------------|----------|--------
auth0-next-custom | 445fec7... | google-login-back | f042bc7... | true   | 1    

VERIFICATION RESULTS
--------------------
1. http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com/pacts/provider/google-login-back/consumer/auth0-next-custom/pact-version/611750a58c1c1b4697e602671bf23021bbe60df7/metadata/Y3ZuPTQ0NWZlYzczOGI1YTQ3NjFiZGE2Mzc5Y2EzNzI4NjA2MmQwNzk0MDE/verification-results/319 (success)

All required verification results are published and successful
~/work/auth0-next-custom


コンシューマ・プロバイダのどちらもデプロイバージョンに対して検証が成功していることが確認できました。


それではいよいよ、can-i-deployをCI/CDに組み込みましょうと言いたいところですが、

ここで少しThinking Timeを挟みました。

というのも、プロバイダのパイプラインにcan-i-deployを組み込む意味があるのか?という疑問が生じたためです。

結論としては、プロバイダのパイプラインにも組み込む必要があるとの思いに至りました。

理由としては、以下の2点が挙げられます。


理由の1つ目:

現状プロバイダのパイプラインで契約の検証を行っていますが、それは常に最新の契約に対して行っており、

現在のコンシューマのデプロイバージョンに対してではありません。

ということは、コンシューマのデプロイバージョンの契約をプロバイダが満たしていなかったが、

契約が更新され、最新の契約をプロバイダが満たすようになった場合、現状のテストは成功するため、

契約をサポートしていないプロバイダがデプロイされてしまいます。


理由の2つ目:

そもそも、マイクロサービスにおいて特定のサービスがプロバイダとしてしか振る舞わないという保証はない気がします。

例えば、投稿バックエンドサービスは投稿フロントエンドのプロバイダですが、

投稿バックエンドサービスからメール通知サービスを呼び出す場合、同サービスはコンシューマとしても振る舞います。

現状役割がプロバイダだけだったとしても、将来的にコンシューマになる可能性を残しておいたほうがいいと考えます。

そうなってくると、プロバイダもコンシューマと同じフローにしておいたほうが良さそうです。


can-i-deployをCI/CDに組み込む

それではいよいよ、can-i-deployをCI/CDに組み込みましょう。

デプロイ前にcan-i-deployするだけでなく、デプロイ後にrecord-deploymentするのもお忘れなく。

デプロイ環境はCodeBuildで指定した環境変数で動的に切り替わるようにしておきます。


コンシューマ

diff --git a/app/api/microposts/service.ts b/app/api/microposts/service.ts
index 6bf857f..e94b165 100644
--- a/app/api/microposts/service.ts
+++ b/app/api/microposts/service.ts
@@ -1,5 +1,4 @@
 import axios from "axios";
-import { NextRequest } from "next/server";
 
 export default function MicropostService(baseUrl: string) {
   const ping = async (): Promise<{message: string}> => {
diff --git a/buildspec.yml b/buildspec.yml
index 7d35759..71b2f6b 100644
--- a/buildspec.yml
+++ b/buildspec.yml
@@ -9,8 +9,10 @@ phases:
   on-failure: ABORT
   commands:
    - npm run test
-   - npm run publish
+   - npm run pact:publish
+   - npm run pact:can-i-deploy
  build:
   on-failure: ABORT
   commands:
-   - aws amplify start-job --app-id xxxxxxxxxxxx --branch-name main --job-type RELEASE
+   - ./deploy.sh
+   - npm run pact:record-deployment
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..89ee0a5
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,28 @@
+#!/bin/bash -e
+
+# Amplifyアプリのパラメータを設定
+APP_ID="xxxxxxxxxxxx"
+BRANCH_NAME="main"
+JOB_TYPE="RELEASE"
+
+# デプロイジョブを開始
+JOB_ID=$(aws amplify start-job --app-id $APP_ID --branch-name $BRANCH_NAME --job-type $JOB_TYPE --query 'jobSummary.jobId' --output text)
+
+echo "Started job with ID: $JOB_ID"
+
+# ジョブの状態を監視
+while true; do
+ JOB_STATUS=$(aws amplify get-job --app-id $APP_ID --branch-name $BRANCH_NAME --job-id $JOB_ID --query 'job.summary.status' --output text)
+
+ echo "Current job status: $JOB_STATUS"
+
+ if [ "$JOB_STATUS" == "SUCCEED" ]; thenecho "Job completed successfully"break
+ elif [ "$JOB_STATUS" == "FAILED" ]; thenecho "Job failed"exit 1
+ fi
+
+ sleep 10
+done
diff --git a/package.json b/package.json
index e3b09ed..677ada9 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,9 @@
   "start": "next start",
   "lint": "next lint",
   "test": "jest",
-  "publish": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest publish ${PWD}/pacts --consumer-app-version=$(git rev-parse HEAD) --branch=$(git branch --contains | cut -d ' ' -f 2) --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}""pact:publish": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest publish ${PWD}/pacts --consumer-app-version=$(git rev-parse HEAD) --branch=$(git branch --contains | cut -d ' ' -f 2) --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}",
+  "pact:can-i-deploy": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}",
+  "pact:record-deployment": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}"
  },
  "dependencies": {
   "@auth0/nextjs-auth0": "^3.5.0",


package.jsonとか汚くてすいません。

この辺綺麗にするのは今後の課題とさせてください。


注意すべき点は、record-deploymentを行うタイミングです。

このパイプラインではamplifyのstart-jobを行うことでデプロイを開始していますが、

start-job実行後にそのままrecord-deploymentを行ってしまうと、

amplifyのjobが失敗した場合に本来デプロイされていないはずのものがrecord-deploymentされてしまい不整合が生じてしまいます。

そのためstart-jobするだけでなく定期的にget-jobにより状態を監視することで、デプロイが成功した場合のみ

record-deploymentを行うことを実現しています。


プロバイダ

diff --git a/buildspec.yml b/buildspec.yml
index 68e505c..6f1d138 100644
--- a/buildspec.yml
+++ b/buildspec.yml
@@ -18,12 +18,14 @@ phases:
    - sleep 5 # 起動待ち
    - docker compose run python ./test.sh
    - docker compose stop
+   - docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant google-login-back --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}
    - aws s3 cp s3://xxxxxxxxxxxx/google_login_back/dev.json .chalice/deployed/dev.json || true
 
  build:
   on-failure: ABORT
   commands:
    - chalice deploy
+   - docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant google-login-back --version $(git rev-parse HEAD) --environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}
 
  post_build:
   on-failure: ABORT


こちらも汚くてすみません。

こちらはコンシューマとは違い同期的にデプロイされるため、そのままrecord-deploymentを行って大丈夫です。


契約の互換性を保った状態でプロバイダを更新する

それではパイプラインが実装できたので、実際にいくつかのケースにおいて期待通りの挙動をするか確認してみましょう。

まずは、契約の互換性を保った状態でプロバイダを更新してみます。


具体的には、投稿一覧取得APIにおいて各投稿につけられたlike(いいね)の数も取得する、という想定とします。

実際にはそもそも投稿保存APIでいいねの数も保存しなければいけないところですが、

CI/CDの挙動を確認したいだけなので一旦ランダムな自然数を返します。


現在の投稿一覧取得APIに関する契約は以下です。

まだいいねの数に関して契約が更新されていないので、今のところ各投稿はcontentとpostedAtしか含まれないようになっています。

現状コンシューマ側でcontentとpostedAtしか使っていなかったとして、プロバイダ側でlikeも返すようになったとしても、

コンシューマにしてみれば必要なものはちゃんと返しているので挙動的には問題ないと考えられます。

なので、契約の互換性は保たれていると考えました。


それでは、プロバイダ側の修正を行います。

diff --git a/app.py b/app.py
index cfc1d9a..a05a689 100644
--- a/app.py
+++ b/app.py
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
 from chalicelib.models import Microposts
 from chalicelib.utils import login
 import jwt
+import random
 
 
 app = Chalice(app_name="google_login_back")
@@ -177,7 +178,7 @@ def get_micropost_for_auth0():
     return Response(body=None, status_code=401)
 
   posts = Microposts.query(payload.get("sub"))
-  res = [post.to_simple_dict() for post in posts]
+  res = [{**post.to_simple_dict(), "like": random.randrange(1000)} for post in posts]
   return Response(body=res, status_code=200)


ローカルでAPIを実行し動作確認を行います。

各投稿にランダムなlikeが振られていることが確認できました。


プッシュ前のPact Matrixは以下です。

これがプッシュ後はどのように変わるでしょうか。


プッシュ後


無事デプロイが成功し、プロバイダのdev環境タグが最新のコミットのバージョンに移動しました。

プロバイダのCI/CDが通ったことで、契約はちゃんと守られているとPactが判断したことがわかります。

コンシューマ側で互換性を保った形での契約の更新

ひとつ上のセクションでは、契約の互換性を保った状態でプロバイダを更新しました。

それの続きで、今度は更新したプロバイダに合った形で契約を更新してみます。


具体的には、投稿一覧取得APIで各投稿にいいねの数を含めるようにしたので、

契約もそれに合う形で各投稿にいいねの数を含む形に修正しましょう。

結果はどうなると思いますか?もちろん、テストが成功し正常にデプロイされると思いますよね?

それでは、実際にやってみます。

diff --git a/app/api/microposts/service.ts b/app/api/microposts/service.ts
index e94b165..a1bac9f 100644
--- a/app/api/microposts/service.ts
+++ b/app/api/microposts/service.ts
@@ -10,7 +10,7 @@ export default function MicropostService(baseUrl: string) {
     return res.json();
   }
 
-  const getAll = async (accessToken: string|undefined): Promise<Array<{content: string, postedAt: string}>> => {
+  const getAll = async (accessToken: string|undefined): Promise<Array<{content: string, postedAt: string, like: number}>> => {
     const response = await axios.get(`${baseUrl}/auth0/microposts`, {
       headers: {
        "Authorization": `Bearer ${accessToken}`,
diff --git a/app/microposts/page.tsx b/app/microposts/page.tsx
index eef1672..f2d9a7e 100644
--- a/app/microposts/page.tsx
+++ b/app/microposts/page.tsx
@@ -10,7 +10,7 @@ import { useUser } from '@auth0/nextjs-auth0/client';
 export default withPageAuthRequired(() => {
  const router = useRouter();
  const { user, error, isLoading } = useUser();
- const [microposts, setMicroposts] = useState(Array<{content: string, postedAt: string}>);
+ const [microposts, setMicroposts] = useState(Array<{content: string, postedAt: string, like: number}>);
  const [content, setContent] = useState("");
 
  useEffect(() => {
@@ -46,6 +46,7 @@ export default withPageAuthRequired(() => {
         <Image className="w-12 h-12 rounded-full mr-2" src={user.picture ?? ''} alt="facePicture" width={600} height={600} />
         <div className="flex-grow">
           <h3 className="m-0 text-lg">{micropost.content}</h3>
+          <p className="my-1 text-sm text-gray-700">{micropost.like} いいね</p>
           <p className="my-1 text-sm text-gray-700">{user.name}</p>
           <p className="my-1 text-sm text-gray-700">{dayjs(micropost.postedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
         </div>
diff --git a/tests/api.pact.spec.ts b/tests/api.pact.spec.ts
index 44d123d..0c2d5b9 100644
--- a/tests/api.pact.spec.ts
+++ b/tests/api.pact.spec.ts
@@ -2,7 +2,7 @@ import MicropostService from "@/app/api/microposts/service";
 import { MatchersV3, PactV3 } from "@pact-foundation/pact";
 import path from "path";
 
-const {eachLike, string, regex, timestamp} = MatchersV3;
+const {eachLike, string, integer, timestamp} = MatchersV3;
 
 const provider = new PactV3({
   dir: path.resolve(process.cwd(), 'pacts'),
@@ -50,6 +50,7 @@ describe('GET /auth0/microposts', () => {
         body: eachLike({
           content: string('Hello, World.'),
           postedAt: timestamp('YYYY-MM-DD HH:mm:ss', '2024-08-21 23:01:01'),
+          like: integer(123)
         })
       }
     });
@@ -57,9 +58,8 @@ describe('GET /auth0/microposts', () => {
     await provider.executeTest(async (mockserver) => {
       const service = MicropostService(mockserver.url);
       const response = await service.getAll('hoge');
-      expect(JSON.stringify(response)).toStrictEqual(
-        JSON.stringify([{content: 'Hello, World.', postedAt: '2024-08-21 23:01:01'}])
-      );
+      const {content, postedAt, like} = response[0];
+      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123);
     })
   });


ローカルで実際に画面を開いてみます。

無事にいいねの数が表示されました。


それではデプロイします。


プッシュ前


プッシュしたところ、パイプラインでエラーが発生しました。

> auth0-next-custom@0.1.0 pact:can-i-deploy
> docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}


Computer says no ¯\_(ツ)_/¯


CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|-----------|----------|--------
auth0-next-custom | 87ad0f7... | google-login-back | ???    | ???   |     


There is no verified pact between version 87ad0f757d72fca441573927194c097da1850b9c of auth0-next-custom and the version of google-login-back currently deployed or released to dev (9a796efc330c87c243e62bc44ecbc53d02e21081)


[Container] 2024/10/03 14:08:27.936109 Command did not exit successfully npm run pact:can-i-deploy exit status 1
[Container] 2024/10/03 14:08:27.941294 Phase complete: PRE_BUILD State: FAILED_WITH_ABORT
[Container] 2024/10/03 14:08:27.941316 Phase context status code: COMMAND_EXECUTION_ERROR Message: Error while executing command: npm run pact:can-i-deploy. Reason: exit status 1


プッシュ後のPact Matrixは以下です。


最初は意外でしたが、考えてみたらそりゃそうだわなって話です。

我々からしたら、更新後の契約をプロバイダが満たしていることは明らかですが、

Pactブローカーからしたら、まだ検証していないので満たしているかどうかはわかりません。

なのでやるべきことははっきりしていて、

コンシューマ側のCI/CDで契約が更新されたら、更新された契約に対してプロバイダ側の検証を行う必要があります。


Pactのドキュメントを漁っていたら、こんなシーケンス図がありました。

次のタスクとしては上の図のようなフローを実現したいです。

コンシューマのCI/CDからプロバイダのCI/CDを発火する(準備編)

今やプロバイダ側での契約検証はプロバイダのCI/CDに組み込まれているので、

コンシューマ側での契約発行をトリガーにプロバイダのCI/CDを動かせば良いです。


Pactには契約が更新されたことを通知するwebhookがあるみたいですが、

APIを呼び出す形なので実現するためにはそのためのリソース(API Gateway, Lambda, IAM Role等々)も作成しなければならず作業量が増えてしまいます。


なので今回は単純にコンシューマ側のパイプラインの中でプロバイダのパイプラインを呼び出す形でできないか検討しました。

つまりCodePipelineを外部からトリガーできればよいです。それには以下のAWSコマンドの実行で実現可能です。

aws codepipeline start-pipeline-execution --name PIPELINE_NAME


めっちゃ簡単ですね。

あとはpublishしてからcan-i-deployするまでの間に上記コマンドをcodebuild内で実行するだけです。

ちなみに、上記を実行するために必要なポリシーのactionはcodepipeline:StartPipelineExecution です。


さて、勘のいい人は気づいたと思いますが、上記はパイプラインを開始するだけなので、

単純に上記をコンシューマのパイプラインに組み込むだけでは、can-i-deployは相変わらず失敗します。


ここで朗報です。can-i-deployの--retry-while-unknownオプションで待つことが可能みたいです。

[--retry-while-unknown=TIMES]  
# The number of times to retry while there is an unknown  
verification result (ie. the provider verification is likely  
still running)  
# Default: 0


検証結果がない場合にリトライしてくれるとのことです。

リトライ回数ということなのでプロバイダ側のCI/CDが正しくトリガーされなかったとか、

プロバイダ側のCI/CDが異常終了して検証結果が反映されないなどの不具合の場合も

無限にリトライし続けるということはないので安心です。

リトライ間隔も--retry-intervalオプションで指定できるようです。

[--retry-interval=SECONDS]  
# The time between retries in seconds. Use in conjuction with  
--retry-while-unknown  
# Default: 10


うまく設計されていますね。感心しました。


それでは今回のユースケースにおいて、妥当なパラメータを検討します。

今回はプロバイダは1つのみですが、依存するプロバイダが複数の場合もプロバイダのCI/CDは

並列で動かすはずなので、遅くても10分には終わるという想定で設定します。

また、リトライ間隔は10秒のままとします。

そうすると最大リトライ回数は60回という計算になります。

(60回リトライしても結果が不明な場合はcan-i-deployの結果を失敗側に倒すということ)

コンシューマのCI/CDからプロバイダのCI/CDを発火する(実践編)

では、実際にコンシューマのパイプラインに適用してみましょう。


まずはポリシーを追加します。


プロバイダのパイプライン名は環境変数 PROVIDER_PIPELINES で管理します。(複数を入れる想定)


buildspecを修正します。

diff --git a/buildspec.yml b/buildspec.yml
index 71b2f6b..41e8996 100644
--- a/buildspec.yml
+++ b/buildspec.yml
@@ -10,6 +10,7 @@ phases:
   commands:
    - npm run test
    - npm run pact:publish
+   - ./trigger_pipelines.sh
    - npm run pact:can-i-deploy
  build:
   on-failure: ABORT
diff --git a/package.json b/package.json
index 677ada9..4c7e9ed 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
   "lint": "next lint",
   "test": "jest",
   "pact:publish": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest publish ${PWD}/pacts --consumer-app-version=$(git rev-parse HEAD) --branch=$(git branch --contains | cut -d ' ' -f 2) --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}",
-  "pact:can-i-deploy": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}",
+  "pact:can-i-deploy": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --retry-while-unknown 60 --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}",
   "pact:record-deployment": "docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}"
  },
  "dependencies": {
diff --git a/trigger_pipelines.sh b/trigger_pipelines.sh
new file mode 100755
index 0000000..85c2d54
--- /dev/null
+++ b/trigger_pipelines.sh
@@ -0,0 +1,5 @@
+#!/bin/bash -xe
+
+for pipeline in $PROVIDER_PIPELINES; do
+ aws codepipeline start-pipeline-execution --name $pipeline
+done


それでは、pushしてみましょう。

CI/CDのログは以下のようになりました。

> auth0-next-custom@0.1.0 pact:can-i-deploy
> docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --retry-while-unknown 60 --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}
Computer says yes \o/ 
CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|------------|----------|--------
auth0-next-custom | b160b72... | google-login-back | 9a796ef... | true   | 1    
VERIFICATION RESULTS
--------------------
1. http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com/pacts/provider/google-login-back/consumer/auth0-next-custom/pact-version/cf071c371a83627a39f9642722633bb0330d74a9/metadata/Y3ZuPWIxNjBiNzJiZGVjOWI1NzhhNTFhMTNmYTVmNWVjNjI0N2UzYjRlYmQ/verification-results/330 (success)
All required verification results are published and successful


push後のPact Matrixです。


更新後の契約に対して無事にプロバイダ側の検証も成功していることが確認できました。

コンシューマ側で互換性を保たない形での契約の更新

次に、コンシューマ側で互換性を保たない形での契約の更新を行ってみます。


先程は投稿一覧で各投稿にいいねの数を追加しましたが、今回は同じノリでお気に入り数を追加してみます。

ですが前回とは違い、先にコンシューマ側で契約を更新し実装を修正してしまいます。


コンシューマの修正差分は以下です。

diff --git a/app/api/microposts/service.ts b/app/api/microposts/service.ts
index a1bac9f..9db8c68 100644
--- a/app/api/microposts/service.ts
+++ b/app/api/microposts/service.ts
@@ -10,7 +10,7 @@ export default function MicropostService(baseUrl: string) {
     return res.json();
   }
 
-  const getAll = async (accessToken: string|undefined): Promise<Array<{content: string, postedAt: string, like: number}>> => {
+  const getAll = async (accessToken: string|undefined): Promise<Array<{content: string, postedAt: string, like: number, favorite: number}>> => {
     const response = await axios.get(`${baseUrl}/auth0/microposts`, {
       headers: {
        "Authorization": `Bearer ${accessToken}`,
diff --git a/app/microposts/page.tsx b/app/microposts/page.tsx
index f2d9a7e..b16a5b5 100644
--- a/app/microposts/page.tsx
+++ b/app/microposts/page.tsx
@@ -10,7 +10,7 @@ import { useUser } from '@auth0/nextjs-auth0/client';
 export default withPageAuthRequired(() => {
  const router = useRouter();
  const { user, error, isLoading } = useUser();
- const [microposts, setMicroposts] = useState(Array<{content: string, postedAt: string, like: number}>);
+ const [microposts, setMicroposts] = useState(Array<{content: string, postedAt: string, like: number, favorite: number}>);
  const [content, setContent] = useState("");
 
  useEffect(() => {
@@ -47,6 +47,7 @@ export default withPageAuthRequired(() => {
         <div className="flex-grow">
           <h3 className="m-0 text-lg">{micropost.content}</h3>
           <p className="my-1 text-sm text-gray-700">{micropost.like} いいね</p>
+          <p className="my-1 text-sm text-gray-700">{micropost.favorite} お気に入り</p>
           <p className="my-1 text-sm text-gray-700">{user.name}</p>
           <p className="my-1 text-sm text-gray-700">{dayjs(micropost.postedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
         </div>
diff --git a/tests/api.pact.spec.ts b/tests/api.pact.spec.ts
index 0c2d5b9..ea14748 100644
--- a/tests/api.pact.spec.ts
+++ b/tests/api.pact.spec.ts
@@ -50,7 +50,8 @@ describe('GET /auth0/microposts', () => {
         body: eachLike({
           content: string('Hello, World.'),
           postedAt: timestamp('YYYY-MM-DD HH:mm:ss', '2024-08-21 23:01:01'),
-          like: integer(123)
+          like: integer(123),
+          favorite: integer(456),
         })
       }
     });
@@ -58,8 +59,8 @@ describe('GET /auth0/microposts', () => {
     await provider.executeTest(async (mockserver) => {
       const service = MicropostService(mockserver.url);
       const response = await service.getAll('hoge');
-      const {content, postedAt, like} = response[0];
-      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123);
+      const {content, postedAt, like, favorite} = response[0];
+      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123 && favorite == 456);
     })
   });


ローカルで動作確認します。まだプロバイダ側が対応していないのでお気に入り数は表示されません。


さて、pushしたのですがCI/CDでdocker pullのLate Limit問題で落ちまくるので急遽対応しました。

(今までも実は落ちていたのですが、数回再実行すれば通っていたのでそのままにしていたのです)

docker loginするようbuildspecを更新し、DOCKER_USER, DOCKER_TOKEN環境変数を追加しました。


気を取り直して再度pushし、コンシューマ側のパイプラインが動き出しました。

上で述べているように、コンシューマ側のパイプラインが動くとそれに伴ってプロバイダ側のパイプラインも動き出します。


プロバイダ側のパイプラインでエラーになりました。

プロバイダ側でエラーになるとコンシューマ側も契約の検証ができないためパイプラインが失敗しデプロイは行われません。

エラー内容は以下です。

=================================== FAILURES ===================================
_ test_pacts[auth0-next-custom with request '\u30e6\u30fc\u30b6\u306e\u5168\u3066\u306e\u6295\u7a3f\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b'1] _
Pact failure details:
Response element 'favorite' is missing at body[0]


各投稿にfavoriteがないので契約は満たされないという結果です。

完全に予想通りです。

その時のPact Matrixは以下です。



では、今度は検証を成功させるべく、プロバイダ側を修正しましょう。

差分は以下です。

diff --git a/app.py b/app.py
index a05a689..c2987e5 100644
--- a/app.py
+++ b/app.py
@@ -178,7 +178,9 @@ def get_micropost_for_auth0():
     return Response(body=None, status_code=401)
 
   posts = Microposts.query(payload.get("sub"))
-  res = [{**post.to_simple_dict(), "like": random.randrange(1000)} for post in posts]
+  res = [
+    {**post.to_simple_dict(), "like": random.randrange(1000), "favorite": random.randrange(1000)} for post in posts
+  ]
   return Response(body=res, status_code=200)


ローカルで動作確認します。


問題なさそうなので、pushします。

Computer says no ¯\_(<span class='ace_cjk'>ツ</span>)_/¯
CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|-----------|----------|--------
auth0-next-custom | b160b72... | google-login-back | ???    | ???   |     
There is no verified pact between the version of auth0-next-custom currently deployed or released to dev (b160b72bdec9b578a51a13fa5f5ec6247e3b4ebd) and version 3b301dd771efeba855e6983d8723033e9f047ef0 of google-login-back
[Container] 2024/10/05 08:31:43.951369 Command did not exit successfully docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant google-login-back --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD} exit status 1


あれ、おかしいですね。can-i-deployで失敗しました。

その時のPact Matrixです。


以下の表だとわかりやすいでしょうか。

新しい契約を満たすようになったプロバイダバージョンにおけるcan-i-deployでは、

上記の真ん中の組み合わせの検証結果がほしいです。

ポイントとしては、あくまで最新の契約ではなく、コンシューマのデプロイバージョンにおける契約に対して

満たしているかを確認する必要があるということです。

しかし、プロバイダのパイプラインで検証しているのは最新の契約のみなので、

上記の表の通り旧契約に対しては未検証という結果になり、プロバイダのパイプラインが失敗します。


では、どうしたらいいのでしょうか?


ボツ案:

コンシューマの新契約バージョン=devデプロイバージョンになれば成功するので、

コンシューマ側のパイプラインをもう一度手動で回せばいいのでは?と思いましたが、

そうするとcan-i-deployでコンシューマの新契約バージョン×プロバイダのdevデプロイバージョンの組み合わせで

検証結果を確認しようとしますがその組み合わせの検証結果は存在しないため成功しませんでした。

コンシューマ側のパイプラインから発火するプロバイダ側のパイプラインでは、

コンシューマの新契約バージョン×プロバイダの最新バージョンに対して検証が行われるため、

求めている検証結果が得られません。


解決策:

一旦契約をロールバックします。

そうすることで今度は先にプロバイダが契約の互換性を保った状態で機能追加した状態になるので、成功するはずです。

契約をロールバックする

流れとしてはまず、コンシューマ側でfavoriteが追加されていない旧契約状態のものに直します。

この時点でプロバイダ側はfavorite対応を行っていますが互換性があるためテストは成功します。

またプロバイダ側のcan-i-deployも

コンシューマ:旧契約バージョン(devデプロイバージョン)×プロバイダ:最新バージョン

の検証が通っているため成功し、最新バージョンでデプロイされます。

コンシューマ側のcan-i-deployも以下の理屈により成功します。

コンシューマ:最新バージョン×プロバイダ:devデプロイバージョン

で検証されますが、コンシューマ側のバージョンについて

最新バージョン=旧契約バージョン=devデプロイバージョンが成り立つため、結果として

コンシューマ:devデプロイバージョン×プロバイダ:devデプロイバージョン

の検証となり、これは(デプロイされている時点で当然ですが)既に検証済みです。


では、順を追って見ていきましょう。


まずはコンシューマ側の修正差分です。

diff --git a/tests/api.pact.spec.ts b/tests/api.pact.spec.ts
index ea14748..720bb43 100644
--- a/tests/api.pact.spec.ts
+++ b/tests/api.pact.spec.ts
@@ -51,7 +51,6 @@ describe('GET /auth0/microposts', () => {
           content: string('Hello, World.'),
           postedAt: timestamp('YYYY-MM-DD HH:mm:ss', '2024-08-21 23:01:01'),
           like: integer(123),
-          favorite: integer(456),
         })
       }
     });
@@ -59,8 +58,8 @@ describe('GET /auth0/microposts', () => {
     await provider.executeTest(async (mockserver) => {
       const service = MicropostService(mockserver.url);
       const response = await service.getAll('hoge');
-      const {content, postedAt, like, favorite} = response[0];
-      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123 && favorite == 456);
+      const {content, postedAt, like} = response[0];
+      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123);
     })
   });


修正できたのでpushします。

コンシューマ側のパイプラインです。

> docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --retry-while-unknown 60 --pacticipant auth0-next-custom --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}
Computer says yes \o/ 
CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|------------|----------|--------
auth0-next-custom | 5b9b833... | google-login-back | 9a796ef... | true   | 1    
VERIFICATION RESULTS
--------------------
1. http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com/pacts/provider/google-login-back/consumer/auth0-next-custom/pact-version/cf071c371a83627a39f9642722633bb0330d74a9/metadata/Y3ZuPTViOWI4MzM4N2FiODdjZjFmMzQ2MGZhNjk3ZTVhZDBlMWIwOTE4MjA/verification-results/331 (success)
All required verification results are published and successful


続いて、プロバイダ側のパイプラインです。

Computer says yes \o/ 
CONSUMER     | C.VERSION | PROVIDER     | P.VERSION | SUCCESS? | RESULT#
------------------|------------|-------------------|------------|----------|--------
auth0-next-custom | b160b72... | google-login-back | 3b301dd... | true   | 1    
VERIFICATION RESULTS
--------------------
1. http://xxxxxxxxxxxx.ap-northeast-1.compute.amazonaws.com/pacts/provider/google-login-back/consumer/auth0-next-custom/pact-version/cf071c371a83627a39f9642722633bb0330d74a9/metadata/Y3ZuPWIxNjBiNzJiZGVjOWI1NzhhNTFhMTNmYTVmNWVjNjI0N2UzYjRlYmQ/verification-results/335 (success)
All required verification results are published and successful


パイプライン実行後のPact Matrixです。


どちらも無事に最新バージョンがデプロイされました。

ここまでくれば、あとはロールバックしていた契約を元に戻すだけです。


コンシューマ側の修正(ロールバックコミットのrevert):

diff --git a/tests/api.pact.spec.ts b/tests/api.pact.spec.ts
index 720bb43..ea14748 100644
--- a/tests/api.pact.spec.ts
+++ b/tests/api.pact.spec.ts
@@ -51,6 +51,7 @@ describe('GET /auth0/microposts', () => {
           content: string('Hello, World.'),
           postedAt: timestamp('YYYY-MM-DD HH:mm:ss', '2024-08-21 23:01:01'),
           like: integer(123),
+          favorite: integer(456),
         })
       }
     });
@@ -58,8 +59,8 @@ describe('GET /auth0/microposts', () => {
     await provider.executeTest(async (mockserver) => {
       const service = MicropostService(mockserver.url);
       const response = await service.getAll('hoge');
-      const {content, postedAt, like} = response[0];
-      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123);
+      const {content, postedAt, like, favorite} = response[0];
+      expect(content == 'Hello, World.' && postedAt == '2024-08-21 23:01:01' && like == 123 && favorite == 456);
     })
   });


pushします。


Pact Matrix:


無事に契約バージョンが最新に更新されました。

さいごに

まとめ

今回は、Pactのcan-i-deployを試してみました。

契約を更新するフローとしては、

  • 古い契約の互換性を保ったまま先にプロバイダを更新し、後から契約を更新する
  • 先に契約を更新し、後からプロバイダを更新する

の2パターンを試してみました。

後者の場合、今のパイプラインの実装だと、

  1. コンシューマ側の新契約バージョンをpush
    1. コンシューマ側のPactテストが成功
    2. コンシューマ側のパイプライン上のcan-i-deployのためプロバイダ側のパイプラインが発火
    3. プロバイダ側のPactテストが失敗(新契約に未対応のため)
    4. コンシューマ側のパイプラインも失敗
  2. プロバイダ側の新契約対応バージョンをpush
    1. プロバイダ側のPactテスト【コンシューマ:最新バージョン(新契約バージョン)×プロバイダ:最新バージョン】が成功
    2. プロバイダ側のパイプライン上のcan-i-deployが失敗(【コンシューマ:デプロイバージョン(旧契約バージョン)×プロバイダ:最新バージョン】の組み合わせの検証結果が存在しないため)
    3. プロバイダ側のパイプラインが失敗

となり積んだ状態になってしまいます。

今回は契約をロールバックする形で対応しましたが、手動で【コンシューマ:デプロイバージョン(旧契約バージョン)×プロバイダ:最新バージョン】の組み合わせの検証を行う方法もありそうです。

もしくは、最初からプロバイダ側のcan-i-deployをする前に上記の検証も行うようパイプラインを組むという手もあるかもしれません。

感想

実用的には、

「コンシューマの契約を更新する際にうっかり互換性のない修正を行ってしまった」

というケースがありそうです。

特に、依存するプロバイダが1個だけならまだ大丈夫かもしれませんが、

数が多くなってくると段々それぞれのプロバイダの状態がどうなっているか管理するのが困難になってくると思われます。

そういうときこそ、can-i-deployが威力を発揮するのではないでしょうか。

そうなってくると、まとめの最後に述べた、

「プロバイダ側のcan-i-deployをする前に【コンシューマ:デプロイバージョン×プロバイダ:最新バージョン】の組み合わせの検証を行うようパイプラインを組む」

というのは対応したほうがいいと思います。

今回はそこまでは対応しませんでしたが、それでもcan-i-deployの便利さはかなり感じました。

ここまでやって初めて、Pactを導入する旨味が出てくる気がします。

マイクロサービスにおいて、複数サービスを同時にデプロイしなくても、パイプラインを回すだけで検証ができ、

安全でないものはデプロイされない仕組みができるというのは素晴らしいと思いました。

付録:buildspecの最終形態

参考までに今回の対応で修正したbuildspecの最終的なファイルの中身を添付します。


コンシューマ:

version: 0.2

phases:
  install:
    on-failure: ABORT
    commands:
      - npm ci
  pre_build:
    on-failure: ABORT
    commands:
      - echo $DOCKER_TOKEN | docker login -u $DOCKER_USER --password-stdin
      - npm run test
      - npm run pact:publish
      - ./trigger_pipelines.sh
      - npm run pact:can-i-deploy
  build:
    on-failure: ABORT
    commands:
      - ./deploy.sh
      - npm run pact:record-deployment


プロバイダ:

version: 0.2

phases:
  install:
    on-failure: ABORT
    commands:
      - pip install -r requirements.txt

  pre_build:
    on-failure: ABORT
    commands:
      - TEMP_ROLE=$(aws sts assume-role --role-arn $ASSUME_ROLE_ARN --role-session-name xxxx)
      - export AWS_ACCESS_KEY_ID=$(echo "${TEMP_ROLE}" | jq -r '.Credentials.AccessKeyId')
      - export AWS_SECRET_ACCESS_KEY=$(echo "${TEMP_ROLE}" | jq -r '.Credentials.SecretAccessKey')
      - export AWS_SESSION_TOKEN=$(echo "${TEMP_ROLE}" | jq -r '.Credentials.SessionToken')
      - echo $DOCKER_TOKEN | docker login -u $DOCKER_USER --password-stdin
      - docker compose up dynamo-test -d
      - chmod 777 dynamodb-test/
      - sleep 5 # 起動待ち
      - docker compose run python ./test.sh
      - docker compose stop
      - docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker can-i-deploy --pacticipant google-login-back --version $(git rev-parse HEAD) --to-environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}
      - aws s3 cp s3://xxxxx/google_login_back/dev.json .chalice/deployed/dev.json || true

  build:
    on-failure: ABORT
    commands:
      - chalice deploy
      - docker run --rm -w ${PWD} -v ${PWD}:${PWD} pactfoundation/pact-cli:latest pact-broker record-deployment --pacticipant google-login-back --version $(git rev-parse HEAD) --environment ${ENV} --broker-base-url=${PACT_BROKER_BASE_URL} --broker-username ${PACT_BROKER_USERNAME} --broker-password ${PACT_BROKER_PASSWORD}

  post_build:
    on-failure: ABORT
    commands:
      - aws s3 cp .chalice/deployed/dev.json s3://xxxxx/google_login_back/dev.json
      - python create_table.py


この記事をシェアする