イベント サブスクリプションを使用して BLOB コンテナーで Azure Functions をトリガーしたメモ

# 概要

[マネージドIDでSQLにアクセス](https://qiita.com/hibohiboo/items/c9c5d0d22229d701a6c7)、[マネージドIDでキュートリガー](https://qiita.com/hibohiboo/items/fad30e70c9ee0722adfd)を試したので、Blobトリガーを試してみる。

[ソースコード](https://github.com/hibohiboo/Yakumi/blob/4f600779c7d521470812b7853c232850a00966d5/packages/batch-functions/src/functions/BlobTriggerEventGrid.ts)

# コード
[チュートリアル: イベント サブスクリプションを使用して BLOB コンテナーで Azure Functions をトリガーする](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-event-grid-blob-trigger?pivots=programming-language-typescript)を参考に作成。

```ts:packages/batch-functions/src/functions/BlobTriggerEventGrid.ts
import { app, InvocationContext, output } from '@azure/functions';
import { isBlobTriggerMetadata } from '../domain/blobTrigger';

const sqlOutput = output.sql({
  commandText: '[dbo].[YakumiCharacter]',
  connectionStringSetting: 'DATABASE_CONNECTION_STRING',
});

export async function BlobTriggerEventGrid(
  blob: unknown, // Buffer
  context: InvocationContext,
): Promise<void> {
  console.log('context'.endsWith('json'), JSON.stringify(context));
  if (!isBlobTriggerMetadata(context.triggerMetadata)) {
    context.warn('context.triggerMetadata is not BlobTriggerMetadata');
    return;
  }
  // zipファイルは処理しない
  if (!context.triggerMetadata.blobTrigger.endsWith('json')) return;
  if (!Buffer.isBuffer(blob)) return;
  const blobContent = blob.toString('utf-8');
  const data = JSON.parse(blobContent);
  const { uid, characterId } = data;
  if (!uid || !characterId) {
    context.log('obj is not Character', data);
    return;
  }
  context.extraOutputs.set(sqlOutput, {
    id: characterId,
    uid,
    data: blobContent,
    updated: new Date(),
  });
}

app.storageBlob('BlobTriggerEventGrid', {
  path: 'web',
  source: 'EventGrid',
  connection: 'TARGET_STORAGE_ACCOUNT',
  handler: BlobTriggerEventGrid,
  extraOutputs: [sqlOutput],
});
```

# インフラ

```bash:infrastructure/bin/createBatch.bash
#!/bin/bash

BIN_DIR=$(cd $(dirname $0) && pwd)
BICEP_DIR=$(cd $BIN_DIR/../biceps && pwd)

# 環境変数読み込み
source $BIN_DIR/.env

cd $BICEP_DIR && az deployment group create \
  --name ExampleDeployment \
  --template-file batch.bicep \
  --parameters batch.bicepparam \
  --parameters \
    functionAppName=$BATCH_NAME \
    uploadStroageAccountName=$UPLOAD_STORAGE_ACCOUNT_NAME \
    sqlServerName=$DB_SERVER_NAME \
    sqlServerDbName=$SQLSERVER_DB_NAME \
  -g $RESOURCE_GROUP_NAME
```

```bash:infrastructure/bin/addBatchTrigger.bash
#!/bin/bash

BIN_DIR=$(cd $(dirname $0) && pwd)
BICEP_DIR=$(cd $BIN_DIR/../biceps && pwd)

# 環境変数読み込み
source $BIN_DIR/.env

EXT_KEY=$(az functionapp keys list -g yakumi -n nkymspaiut5dsazfunctionsapp |  jq -r '.systemKeys.blobs_extension'  | tr -d '[:space:]' )

cd $BICEP_DIR && az deployment group create \
  --name ExampleDeployment \
  --template-file eventGrid.bicep \
  --parameters \
    functionAppName=$BATCH_NAME \
    blobExtensionKey=$EXT_KEY \
    uploadStroageAccountName=$UPLOAD_STORAGE_ACCOUNT_NAME \
  -g $RESOURCE_GROUP_NAME

```

```biceps:infrastructure/biceps/core/eventGrid/subscription.bicep
param functionAppName string
param blobExtensionKey string

var eventSubName = 'subToStorage'
var systemTopicName = 'mystoragesystemtopic'

resource systemTopic 'Microsoft.EventGrid/systemTopics@2023-12-15-preview' existing = {
  name: systemTopicName
}

var endpoint = 'https://${functionAppName}.azurewebsites.net/runtime/webhooks/blobs?functionName=Host.Functions.BlobTriggerEventGrid&code=${blobExtensionKey}'

resource eventSubscription 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2023-12-15-preview' = {
  parent: systemTopic
  name: eventSubName
  properties: {
    destination: {
      properties: {
        endpointUrl: endpoint
      }
      endpointType: 'WebHook'
    }
    filter: {
      includedEventTypes: [
        'Microsoft.Storage.BlobCreated' // 	BLOB が作成または置換されたときにトリガー.  https://learn.microsoft.com/ja-jp/azure/event-grid/event-schema-blob-storage
      ]
    }
  }
}
```

マネージドIDでBlobに対するロールの付与が必要。
キューに対するロールはこちらの方法だと[blobトリガーと異なり](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-storage-blob-trigger?tabs=python-v2%2Cisolated-process%2Cnodejs-v4%2Cextensionv5&pivots=programming-language-typescript#identity-based-connections)不要となる。

```biceps:infrastructure/biceps/core/eventGrid/topic.bicep
param location string
param uploadStroageAccountName string

var systemTopicName = 'mystoragesystemtopic'


resource uploadedStorageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' existing = {
  name: uploadStroageAccountName
}

resource systemTopic 'Microsoft.EventGrid/systemTopics@2023-12-15-preview' = {
  name: systemTopicName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    source: uploadedStorageAccount.id
    topicType: 'Microsoft.Storage.StorageAccounts'
  }
}
```

```biceps:infrastructure/biceps/batch.bicep
param location string = resourceGroup().location
param functionsRuntime object
param functionAppName string
param uploadStroageAccountName string
param sqlServerName string
param sqlServerDbName string

var storageAccountName = '${uniqueString(resourceGroup().id)}azfunctions'
var applicationInsightsName = '${uniqueString(resourceGroup().id)}applicationinsights'
var logAnalyticsName = '${uniqueString(resourceGroup().id)}logAnalytics'
var environments = [
  {
    name: 'TARGET_STORAGE_ACCOUNT__accountName'
    value: uploadStroageAccountName
  }
  {
    name: 'TARGET_STORAGE_ACCOUNT__blobServiceUri'
    value: 'https://${uploadStroageAccountName}.blob.core.windows.net'
  }
  {
    name: 'TARGET_STORAGE_ACCOUNT__queueServiceUri'
    value: 'https://${uploadStroageAccountName}.queue.core.windows.net'
  }
  {
    name: 'DATABASE_CONNECTION_STRING'
    value: 'Server=tcp:${sqlServerName}.database.windows.net;Authentication=Active Directory Default; Database=${sqlServerDbName};'
  }
]
module myFunctionsApplicationInsights 'core/host/applications.bicep' = {
  name: 'myFunctionsApplicationInsights'
  params: {
    location: location
    applicationInsightsName: applicationInsightsName
    logAnalyticsName: logAnalyticsName
  }
}

module myFunctionsStorage 'core/storage/storage-account.bicep' = {
  name: 'myFunctionsStorage'
  params: {
    location: location
    storageAccountName: storageAccountName
  }
}

module myFunctions 'core/host/functions.bicep' = {
  name: 'myFunctions'
  params: {
    functionAppName: functionAppName
    location: location
    storageAccountName: storageAccountName
    kind: functionsRuntime.kind
    runtime: functionsRuntime.runtime
    linuxFxVersion: functionsRuntime.linuxFxVersion
    extensionVersion: functionsRuntime.extensionVersion
    applicationInsightsInstrumentationKey: myFunctionsApplicationInsights.outputs.applicationInsightsInstrumentationKey
    environments: environments
  }
}

// 組み込みロール: https://learn.microsoft.com/ja-jp/azure/role-based-access-control/built-in-roles
var storageRoleDefinitionId= 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' // ストレージ BLOB データ共同作成者
var principalId = myFunctions.outputs.principalId
module myStorageRole 'core/rbac/role.bicep' = {
  name: 'myStorageRole'
  params: {
    uploadStroageAccountName: uploadStroageAccountName
    principalId: principalId
    roleDefinitionId: storageRoleDefinitionId
  }
}

```


# デプロイ

Functionsをデプロイして`blobs_extension`が作成されたのちにEventGridを設定する流れとする。

```
./infrastructure/bin/createBatch.bash
cd packages/batch-functions && npm run deploy
./infrastructure/bin/addBatchTrigger.bash
```

# 確認
BlobトリガーでデータをDBに保存できた。[*](https://gentle-smoke-0024c9c00.5.azurestaticapps.net/app/fall-magia/character/tutorial/main)

# 参考: contextからとれる情報
第２引数。第１引数はBlobデータがBuffer型で入っている。

```json
{
  "invocationId": "00000000-0000-0000-0000-000000000000",
  "functionName": "BlobTriggerEventGrid",
  "extraInputs": {},
  "extraOutputs": {},
  "traceContext": {
    "traceParent": "00-00000000000000000000000000000000-0000000000000000-00",
    "traceState": "",
    "attributes": { "OperationName": "BlobTriggerEventGrid" }
  },
  "triggerMetadata": {
    "blobTrigger": "$web/sample-path/character-data/path-to-blob/sample-file.json",
    "uri": "https://example.blob.core.windows.net/$web/sample-path/character-data/path-to-blob/sample-file.json",
    "properties": {
      "lastModified": "2024-09-25T22:53:51+00:00",
      "createdOn": "2024-05-27T13:46:29+00:00",
      "metadata": {},
      "objectReplicationDestinationPolicyId": null,
      "objectReplicationSourceProperties": null,
      "blobType": 0,
      "copyCompletedOn": "0001-01-01T00:00:00+00:00",
      "copyStatusDescription": null,
      "copyId": null,
      "copyProgress": null,
      "copySource": null,
      "copyStatus": 0,
      "blobCopyStatus": null,
      "isIncrementalCopy": false,
      "destinationSnapshot": null,
      "leaseDuration": 0,
      "leaseState": 0,
      "leaseStatus": 1,
      "contentLength": 362814,
      "contentType": "application/octet-stream",
      "eTag": {},
      "contentHash": "00000000000000000000000000000000",
      "contentEncoding": null,
      "contentDisposition": null,
      "contentLanguage": null,
      "cacheControl": null,
      "blobSequenceNumber": 0,
      "acceptRanges": "bytes",
      "blobCommittedBlockCount": 0,
      "isServerEncrypted": true,
      "encryptionKeySha256": null,
      "encryptionScope": null,
      "accessTier": "Hot",
      "accessTierInferred": true,
      "archiveStatus": null,
      "accessTierChangedOn": "0001-01-01T00:00:00+00:00",
      "versionId": null,
      "isLatestVersion": false,
      "tagCount": 0,
      "expiresOn": "0001-01-01T00:00:00+00:00",
      "isSealed": false,
      "rehydratePriority": null,
      "lastAccessed": "0001-01-01T00:00:00+00:00",
      "immutabilityPolicy": { "expiresOn": null, "policyMode": null },
      "hasLegalHold": false
    },
    "metadata": {}
  },
  "options": {
    "trigger": {
      "path": "web",
      "source": "EventGrid",
      "connection": "TARGET_STORAGE_ACCOUNT",
      "type": "blobTrigger",
      "name": "blobTrigger00000000",
      "direction": "in"
    },
    "extraInputs": [],
    "extraOutputs": []
  }
}
```


# 検討
マネージド関数ではBlobトリガーを作れないので普通の関数として作成。
[apis-functions](https://learn.microsoft.com/ja-jp/azure/static-web-apps/apis-functions)  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/530dbce5-d97f-2ae0-936e-07fa8b4004d1.png)

# 参考

[Azure Functions の Azure SQL 出力バインド](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-azure-sql-output?tabs=isolated-process%2Cnodejs-v4%2Cpython-v2&pivots=programming-language-typescript)
[SQLバインド接続文字列](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-azure-sql?tabs=isolated-process%2Cextensionv4&pivots=programming-language-javascript#sql-connection-string) [ConnectionString](https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.data.sqlclient.sqlconnection.connectionstring?view=sqlclient-dotnet-core-5.0&preserve-view=true#Microsoft_Data_SqlClient_SqlConnection_ConnectionString)  [マネージド ID 接続の使用](https://learn.microsoft.com/ja-jp/azure/app-service/tutorial-connect-msi-sql-database?tabs=windowsclient%2Cefcore%2Cdotnet#3-modify-your-project)

[Azure Functions の Azure Blob Storage トリガー](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-storage-blob-trigger?tabs=python-v2%2Cisolated-process%2Cnodejs-v4%2Cextensionv5&pivots=programming-language-typescript)  
[クイック スタート: Bicep を使用して BLOB ストレージ イベントを Web エンドポイントにルーティングする](https://learn.microsoft.com/ja-jp/azure/event-grid/blob-event-quickstart-bicep?tabs=CLI)


[Blob Event Grid Triggerを動かしてみる](https://qiita.com/uzresk/items/2fdc01959662f0d5a5f2)
[Event Grid経由でのBlob storage triggerでBlobの変更をSlackに自動通知する](https://techblog.ap-com.co.jp/entry/2024/08/23/095244)  
[EventGridを使用してAzureリソースのデプロイ完了をフックする](https://qiita.com/kazu_yasu/items/a22c35f54fb665642c00)
[Event Grid ソースとしての Azure Blob Storage](https://learn.microsoft.com/ja-jp/azure/event-grid/event-schema-blob-storage?tabs=cloud-event-schema)