AWS Device Farmのテストが完了したことを通知する

2021.09.02

いわさです。

AWS Device Farmで自動テストを実施しますが、テストには数分〜数時間かかります。
テストが終わるまでモニターの前で張り付いたりリロードを繰り返すのはちょっとつらいなと感じました。
しかし、他の自動化サービスにはある、AmazonSNS連携などの機能がDevice Farmには用意されていません。

そこで、今回はいくつかのサービスを組み合わせて実現してみました。

構成と仕組み

まず、Device Farmはテストを実行する際に、ScheduleRunを実行するため、CloudTrailのイベントレコードが発生します。
しかし、テストが完了したというイベントは発生しません。CloudTrailにもログにも出力されません。

ただし、AWS CLIにてDevice Farmのget-runコマンドを使うと、テストの実施状況を確認することが出来ます。

そこで今回はEventBridgeで「テストをスケジュールした」というCloudTrailイベントレコードを検知し、そこからStep Functionsを使って、定期的にget-runコマンドを実行して自動テストのステータスをチェックし、ステータスがコンプリートになったタイミングでテスト結果をSNSへ通知する、という方法をとりました。

なお、Device Farmがオレゴンでのみ動作するので、構成リソースは全てオレゴンリージョンで作成する必要があります。

Device Farm

今回は汎用的な通知の仕組みを考えているため、Device Farmのテストはどういったものでも良いです。
前回の記事を参考にしてください。

EventBridge

まず、ScheduleRunのCloudTrailイベントレコードは以下のようになっています。

{
    "eventVersion": "1.08",
    "userIdentity": {
        "type": "AssumedRole",
        "principalId": "hogehogehoge",
        "arn": "hogehogehoge",
        "accountId": "hogehogehoge",
        "accessKeyId": "hogehogehoge",
        "sessionContext": {
            "sessionIssuer": {
                "type": "Role",
                "principalId": "hogehogehoge",
                "arn": "hogehogehoge",
                "accountId": "hogehogehoge",
                "userName": "hogehogehoge"
            },
            "webIdFederationData": {},
            "attributes": {
                "creationDate": "2021-08-31T07:17:10Z",
                "mfaAuthenticated": "true"
            }
        }
    },
    "eventTime": "2021-08-31T07:37:42Z",
    "eventSource": "devicefarm.amazonaws.com",
    "eventName": "ScheduleRun",
    "awsRegion": "us-west-2",
    "sourceIPAddress": "111.111.111.111",
    "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36",
    "requestParameters": {
        "projectArn": "arn:aws:devicefarm:us-west-2:hogehogehoge:project:hogehogehoge",
        "configuration": {
            "location": {
                "longitude": -122.3491,
                "latitude": 47.6204
            },
            "auxiliaryApps": [],
            "customerArtifactPaths": {
                "deviceHostPaths": [
                    "$WORKING_DIRECTORY"
                ]
            },
            "locale": "en_US",
            "radios": {
                "wifi": true,
                "bluetooth": false,
                "gps": true,
                "nfc": true
            },
            "billingMethod": "METERED",
            "networkProfileArn": "arn:aws:devicefarm:us-west-2::networkprofile:public"
        },
        "devicePoolArn": "arn:aws:devicefarm:us-west-2:hogehogehoge:devicepool:hogehogehoge",
        "test": {
            "parameters": {},
            "type": "BUILTIN_FUZZ"
        },
        "appArn": "arn:aws:devicefarm:us-west-2:hogehogehoge:upload:hogehogehoge",
        "name": "Payload.ipa"
    },
    "responseElements": {
        "run": {
            "name": "Payload.ipa",
            "eventCount": 10,
            "jobTimeoutMinutes": 150,
            "arn": "arn:aws:devicefarm:us-west-2:hogehogehoge:run:hogehogehoge",
            "billingMethod": "METERED",
            "radios": {
                "wifi": true,
                "bluetooth": false,
                "gps": true,
                "nfc": true
            },
            "devicePoolArn": "arn:aws:devicefarm:us-west-2:hogehogehoge:devicepool:hogehogehoge",
            "skipAppResign": false,
            "location": {
                "longitude": -122.3491,
                "latitude": 47.6204
            },
            "totalJobs": 1,
            "result": "PENDING",
            "created": "Aug 31, 2021 7:37:42 AM",
            "started": "Aug 31, 2021 7:37:42 AM",
            "seed": 1905285647,
            "completedJobs": 0,
            "customerArtifactPaths": {
                "deviceHostPaths": [
                    "$WORKING_DIRECTORY"
                ]
            },
            "counters": {
                "stopped": 0,
                "warned": 0,
                "failed": 0,
                "passed": 0,
                "skipped": 0,
                "total": 0,
                "errored": 0
            },
            "locale": "en_US",
            "appUpload": "arn:aws:devicefarm:us-west-2:hogehogehoge:upload:hogehogehoge",
            "type": "BUILTIN_FUZZ",
            "status": "SCHEDULING",
            "platform": "IOS_APP",
            "networkProfile": {
                "description": " ",
                "uplinkDelayMs": 0,
                "uplinkLossPercent": 0,
                "downlinkDelayMs": 0,
                "downlinkBandwidthBits": 104857600,
                "downlinkLossPercent": 0,
                "uplinkBandwidthBits": 104857600,
                "downlinkJitterMs": 0,
                "type": "CURATED",
                "uplinkJitterMs": 0,
                "arn": "arn:aws:devicefarm:us-west-2::networkprofile:public",
                "name": "Full"
            }
        }
    },
    "requestID": "a05f9cf7-8f26-4a84-9be4-92b8491ee3ec",
    "eventID": "11cf129e-cb34-4e0f-b72c-e767d448e64c",
    "readOnly": false,
    "eventType": "AwsApiCall",
    "managementEvent": true,
    "recipientAccountId": "hogehogehoge",
    "eventCategory": "Management"
}

上記イベントレコードをもとにEventBridgeのカスタムイベントパターンを作成します。
CloudTrail経由でイベントソースがDevice Farmで、ScheduleRunイベントのものを検知します。

ですのでイベントパターンは以下のようになります。

{
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["devicefarm.amazonaws.com"],
    "eventName": ["ScheduleRun"]
  }
}

このイベントレコードのresponseElements.run.arnを使って、AWS CLIでテストステータスを確認したいと思います。

Lambda

ランタイムはPython3.9で作成しました。

Boto3でget_runしているだけです。
余談ですが、Boto3のドキュメントページが重いの私だけですかね?

前述したとおり、EventBridgeのインプットからARNを取得して使っています。

import json, boto3

def lambda_handler(event, context):
    client = boto3.client('devicefarm')
    response = client.get_run(
        arn=event['detail']['responseElements']['run']['arn']
        )
    return json.loads(json.dumps(response, default=str))

なお、Lambda関数に設定したロールにはDevice Farmへの読み取り権限が必要になりますので、以下のようなカスタムポリシーを作成してアタッチしておきましょう。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "devicefarm:GetTest",
                "devicefarm:GetRun",
                "devicefarm:GetVPCEConfiguration",
                "devicefarm:GetRemoteAccessSession",
                "devicefarm:GetOfferingStatus",
                "devicefarm:GetSuite",
                "devicefarm:GetUpload",
                "devicefarm:GetTestGridSession",
                "devicefarm:GetDevicePoolCompatibility",
                "devicefarm:GetDevicePool",
                "devicefarm:GetInstanceProfile",
                "devicefarm:GetAccountSettings",
                "devicefarm:GetJob",
                "devicefarm:GetNetworkProfile",
                "devicefarm:GetDevice",
                "devicefarm:GetProject",
                "devicefarm:GetDeviceInstance",
                "devicefarm:GetTestGridProject"
            ],
            "Resource": "*"
        }
    ]
}

上記関数は最低限必要な部分だけ実装しています。
必要に応じて、エラー処理など実装するようにしてください。

Step Functions

Step Functionsでワークフロー(繰り返しや分岐)を組み立てていきます。
最近ワークフローエディターが登場したので直感的に組み立てやすくなったんじゃないかなと思います。

今回でいうと、Choice StateでLambdaの実行結果からステータスを判定し、テストが終了していたらSNSへパブリッシュを、テスト中であればWait Stateで5秒待機してまたLambdaを再実行します。

なお、AWS CLIでget runを行った際の結果は以下のようになります。
よって、$.Payload.run.statusを使って判定を行います。

[cloudshell-user@ip-10-0-85-132 ~]$ aws devicefarm get-run --arn arn:aws:devicefarm:us-west-2:hogehogehoge:run:hogehogehoge
{
    "run": {
        "arn": "arn:aws:devicefarm:us-west-2:hogehogehoge:run:hogehogehoge",
        "name": "Payload.ipa",
        "type": "BUILTIN_FUZZ",
        "platform": "IOS_APP",
        "created": "2021-08-31T07:37:42.855000+00:00",
        "status": "COMPLETED",
        "result": "PASSED",
        "started": "2021-08-31T07:37:42.855000+00:00",
        "stopped": "2021-08-31T07:46:04.028000+00:00",
        "counters": {
            "total": 3,
            "passed": 3,
            "failed": 0,
            "warned": 0,
            "errored": 0,
            "stopped": 0,
            "skipped": 0
        },
        "totalJobs": 1,
        "completedJobs": 1,
        "billingMethod": "METERED",
        "deviceMinutes": {
            "total": 6.6,
            "metered": 1.4,
            "unmetered": 0.0
        },
        "networkProfile": {
            "arn": "arn:aws:devicefarm:us-west-2::networkprofile:public",
            "name": "Full",
            "description": " ",
            "type": "CURATED",
            "uplinkBandwidthBits": 104857600,
            "downlinkBandwidthBits": 104857600,
            "uplinkDelayMs": 0,
            "downlinkDelayMs": 0,
            "uplinkJitterMs": 0,
            "downlinkJitterMs": 0,
            "uplinkLossPercent": 0,
            "downlinkLossPercent": 0
        },
        "parsingResultUrl": "https://prod-us-west-2-results.s3-us-west-2.amazonaws.com/hogehogehoge/hogehogehoge/parsingResult.txt",
        "seed": 1905285647,
        "appUpload": "arn:aws:devicefarm:us-west-2:hogehogehoge:upload:1e3126d7-dddb-4a62-9e20-7dc017add129/e127087f-7634-4707-b5e0-1ea4f01da23e",
        "eventCount": 10,
        "jobTimeoutMinutes": 150,
        "devicePoolArn": "arn:aws:devicefarm:us-west-2:hogehogehoge:devicepool:1e3126d7-dddb-4a62-9e20-7dc017add129/5a7eecac-ebae-4bb9-b938-541c3b90737a",
        "locale": "en_US",
        "radios": {
            "wifi": true,
            "bluetooth": false,
            "nfc": true,
            "gps": true
        },
        "location": {
            "latitude": 47.6204,
            "longitude": -122.3491
        },
        "customerArtifactPaths": {
            "deviceHostPaths": [
                "$WORKING_DIRECTORY"
            ]
        },
        "skipAppResign": false
    }
}

もしCOMPLETEしていない場合は、前述のとおりWaitしたあとにまたLambdaを再実行する必要があるのですが、このままLambdaを再実行すると、Lambdaの出力がWait Stateの入出力となり、それが再実行したLambdaの入力となってしまいます。

解決策として、コンテキストオブジェクトを使いました。
これを使うとステートマシンの入力にアクセスすることが出来るのでLambdaの初回実行のパラメータを再現することが可能です。

この辺りはのんピ先生にヒントというかそのまんまの解答を頂きました。
どうもありがとうございました。さすがです。

そして、最終的なステートマシンは以下のようになりました。
ハイライト部分はアカウントや環境に応じて変わる部分なので適宜変更してください。

{
  "Comment": "hogehoge",
  "StartAt": "Lambda Invoke",
  "States": {
    "Lambda Invoke": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-west-2:hogehogehoge:function:check-run-result:$LATEST",
        "Payload.$": "$"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "Next": "Choice"
    },
    "Choice": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.Payload.run.status",
          "StringMatches": "COMPLETED",
          "Next": "SNS Publish"
        }
      ],
      "Default": "Wait"
    },
    "SNS Publish": {
      "Type": "Task",
      "Resource": "arn:aws:states:::sns:publish",
      "Parameters": {
        "Message.$": "$",
        "TopicArn": "arn:aws:sns:us-west-2:hogehogehoge:iwasa-general-mail"
      },
      "End": true
    },
    "Wait": {
      "Type": "Wait",
      "Seconds": 5,
      "Next": "Lambda Invoke",
      "OutputPath": "$$.Execution.Input"
    }
  }
}

さいごに

実行してテスト終了後にEメールで通知がされるだけなので割愛します。
SNSに渡るパラメータは生データのままなので加工するなりしてみてください。
パラメータにはステータス以外にテストが成功したか、失敗したか。デバイスを何分使用したかなどの情報が含まれていますので、色々とおもしろいことが出来そうですね。

参考