Automation PilotでサービスインスタンスのBinding Secretを更新する

## 背景
Cloud Foundryで実行するアプリケーションは、XAUAAサービスインスタンスとバインドすることにより、呼び出し元の認証・認可を行っています。「バインド」とは、アプリケーションがサービスインスタンスの認証情報（クライアントID、クライアントシークレット等）を使ってサービスにアクセスできるようにするため、アプリケーションとサービスインスタンスをひもづけることです。

この認証情報には有効期限がないため、悪意のある人に知られてしまうとアプリケーションにアクセスし放題になってしまいます。このため、SAP BTP Security Reccomendationでは、定期的にシークレットを更新することを推奨しています（[BTP-UAA-0003](https://help.sap.com/docs/btp/sap-btp-security-recommendations-c8a9bb59fe624f0981efa0eff2497d7d/sap-btp-security-recommendations?seclist-index=BTP-UAA-0004)）。

## シークレットの更新手順
シークレットの更新手順は、以下のHelp Portalページにあります。
[Rotate Binding Secrets](https://help.sap.com/docs/btp/sap-business-technology-platform/rotate-binding-secrets)

### 前提：Binding Secretを使う
前提として、サービスインスタンスごとのシークレットではなく、バインドされたアプリケーションごとのシークレット（Binding Secret）を使用することが推奨されています。Binding Secretにしておけば、一つのアプリケーションのBinding Secretを更新しても、同じサービスインスタンスにバインドされた他のアプリケーションには影響しないからです。

新規にXSUAAのサービスインスタンスを作成する際は、xs-security.jsonで以下のように設定します。
```json
  "oauth2-configuration": {
    "credential-types": ["binding-secret"]
}
```

Binding Secretへの移行方法は以下のページに記載されています。
[Migrate from Instance Secrets to Binding Secrets](https://help.sap.com/docs/btp/sap-business-technology-platform/migrate-from-instance-secrets-to-binding-secrets)

### 更新手順
手順は以下の3ステップです。

1. アプリケーションをサービスインスタンスからアンバインドする（バインドを外す）
    `cf unbind-service <APP_NAME> <SERVICE_INSTANCE>`
1. アプリケーションをサービスインスタンスにバインドしなおす
    `cf cf bind-service <APP_NAME> <SERVICE_INSTANCE>`
1. アプリケーションを再起動する
    `cf restart <APP_NAME>`

実際には、対象のアプリケーション・サービスインスタンスは複数あり、かつこの作業を定期的に実行する必要があります。そこで、Automation Pilotを使って自動化する方法を検討します。

## Automation Pilotで自動化する
Automation Pilotには、以下の事前定義されたコマンドが用意されています。

|コマンド  |カタログ|用途  |
|---|---|---|
|UnbindCfServiceInstance  |cf-sapcp  |アプリケーションをサービスインスタンスからアンバインドする|
|BindCfServiceInstance  |cf-sapcp  |アプリケーションをサービスインスタンスにバインドする|
|RestartCfApp|applm-sapcp|アプリケーションを再起動する|

そこでまず、上記のコマンドを3つつなげて実行するコマンド(1)を作成します。さらに、コマンド(1)を繰り返し実行するためのコマンド(2)を作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/d3881cec-a7a9-a1a9-ec8d-94519fa007f0.png)

### 1. インプットの定義（グローバル）
コマンド作成の前に、認証情報を再利用できるようにInputとして定義しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/9c8a89c2-d1b8-95bc-ddec-61f08a57a733.png)

|キー  |説明  |設定値の例  |
|---|---|---|
|org  |Cloud Foudry組織のGUID。`cf org <ORG> --guid`で取得  |ee5e5a44-67b2-45c2-92f9-7429d7e39a92  |
|space  |Clouf FoundryスペースのGUID。`cf space <SPACE> --guid`で取得  |46142ba6-2440-4cce-b81a-a2cc136c61d7  |
|region|Cloud Foundryのリージョン|cf-ap21 ※トライアルアカウントのcf-us10-001では標準コマンドが動かない|
|user|Cloud Foundryユーザ（メールアドレス）|-|
|password|Cloud Foundryユーザのパスワード|-|

### 2. コマンド1を作成
#### 2.1. コマンドのインプット定義
インプットは以下の2つです。

|キー  |タイプ|説明  |デフォルト値  |
|---|---|---|---|
|credentials  |object|標準コマンドに渡す認証情報を格納したオブジェクト  |ステップ1で定義したインプット  |
|target|object|シークレットを更新する対象のアプリケーションとサービスインスタンスの組|{"app": "8eb22a33-f5e2-4c13-8900-07c986b7478e", "serviceInstance": "d3ec6781-9818-4e09-bee6-6cbe090c435b"}|

- appに設定する値は`cf app <APP_NAME> --guid`で確認可能
- serviceInstanceに設定する値は`cf service <SERVICE> --guid`で確認可能

:::note warn
デフォルト値は、このコマンド単独で実行する場合は設定しておくと便利ですが、他のコマンドから呼び出す段階では削除したほうがよいです。デフォルト値が設定されていると呼び出し側でパラメータが正しく設定されていなくても、コマンドのデフォルト値のおかげで正常に動いてしまい、設定不備に気づくのが遅れるためです。
:::

#### 2.2. コマンドのアウトプット定義
アウトプットには、3つのコマンドを実行結果を格納するためのオブジェクトを定義します。

|キー  |タイプ|説明  |
|---|---|---|
|unbind  |string|unbindコマンドの結果を格納  |
|rebind  |string|rebindコマンドの結果を格納  |
|restart  |string|resartコマンドの結果を格納  |

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/7f23dbd8-ba26-6235-b354-acc000558770.png)


#### 2.3. コマンド呼び出し
以下のように3つのコマンドを配置します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/a89352f3-2f3d-afda-62e4-81a5e9ef059d.png)

##### unbind
unbindでは、`cf-sapcp:UnbindCfServiceInstance:1`コマンドを呼び出します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/0dc3ec38-b141-7010-8477-0b4c0befe3cb.png)

パラメータを以下のようにマッピングします。

|キー  |設定値|
|---|---|
|app  |$(.execution.input.target.app)  |
|org  |$(.execution.input.credentials.org)  |
|region  |$(.execution.input.credentials.region)  |
|serviceInstance  |$(.execution.input.target.serviceInstance)  |
|space  |$(.execution.input.credentials.space)  |
|password  |$(.execution.input.credentials.password)  |
|user  |$(.execution.input.credentials.user)  |

##### rebind
rebindでは、`cf-sapcp:BindCfServiceInstance:1`コマンドを呼び出します。パラメータマッピングはunbindと同じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/c129a5ab-303c-09c8-61ca-462ca5430a15.png)

##### restart
restartでは、`applm-sapcp:RestartCfApp:1`コマンドを呼び出します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/1b9eef77-5428-9885-efcb-7494e969411c.png)

先の2つのコマンドとはカタログが違うためか、パラメータのネーミングも違っています。マッピングは以下のようになります。

|キー  |設定値|
|---|---|
|region  |$(.execution.input.credentials.region)  |
|resourceGroup  |$(.execution.input.credentials.space)  |
|resourceName  |$(.execution.input.target.app)  |
|subAccount  |$(.execution.input.credentials.org)  |
|password  |$(.execution.input.credentials.password)  |
|rollingRestart|true (default)|
|useAdditionalInstance|true|
|user  |$(.execution.input.credentials.user)  |

このコマンドは、デフォルトではアプリケーションにダウンタイムを生じさせないように、インスタンスを順番に再起動します（rollingRestart）。もともとのインスタンスの数が1つしかない場合、rollingRestartを実行するにはuseAdditionalInstanceに`true`を指定する必要があります。

#### 2.4. アウトプットのマッピング
各コマンドからのアウトプットを、自分で定義したコマンドのアウトプットにマッピングします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4578fd5f-b44f-438e-9d9f-ca87b1528fba.png)

|キー  |設定値|
|---|---|
|unbind  |$(.unbind.output.binding) |
|rebind  |$(.rebind.output.binding)  |
|restart  |$(.restart.output.summary)  |

#### 2.5. テスト実行
"Trigger"ボタンでコマンドを実行し、結果を確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/3fe382aa-71a1-ebd4-e4de-4fd5c02d3da0.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/96f3a65d-85e2-1a33-0634-91dd63fbf574.png)

アウトプットは以下のように設定されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/58ad8cea-2cfb-0dd6-eaf1-9fd0594ab104.png)

### 3. コマンド2を作成
#### 3.1. コマンドのインプット定義
インプットは以下の2つです。

|キー  |タイプ|説明  |デフォルト値  |
|---|---|---|---|
|credentials  |object|標準コマンドに渡す認証情報を格納したオブジェクト  |ステップ1で定義したインプット  |
|targets|array|シークレットを更新する対象のアプリケーションとサービスインスタンスの組を格納した配列|[{"app": "8eb22a33-f5e2-4c13-8900-07c986b7478e", "serviceInstance": "d3ec6781-9818-4e09-bee6-6cbe090c435b"}]|

一つ目のコマンドとの違いは、こちらのコマンドは「シークレットを更新する対象のアプリケーションとサービスインスタンスの組」の配列を受け取ることです。

#### 3.2. コマンドのアウトプット定義
アウトプットでは、このあと定義するforEachコマンドのアウトプットである配列をそのまま受け取ります。

|キー  |タイプ|説明  |
|---|---|---|
|results  |array|ForEachコマンドの結果を格納  |

#### 3.3. コマンド呼び出し
`utils-sapcp:ForEach:1`という事前定義コマンドを使って同じコマンドを繰り返し呼び出します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/c248c985-d027-b77c-5e74-f6981e1335c2.png)

パラメータを以下のようにマッピングします。
|キー  |設定値|
|---|---|
|command  |呼び出すコマンド（例：`MyCatalog-T001565R7:RoteteBindingSecrets3:1`）  |
|inputs  |$(.execution.input.targets \| [{ "target": .[] }] )  |
|defaultValues  |$(.execution.input.credentials \| {"credentials": .} )  |

inputsとdefaultValuesには、[DynamicExpression](https://help.sap.com/docs/automation-pilot/automation-pilot/dynamic-expression)を使用してインプットの値をマッピングしています。何をしているかについて以下で説明します。

inputsは実行するコマンドのインプットを配列として受け取ります。今回実行するコマンドの場合、インプットは以下のようになっています。

```json
{ 
  "credentials": {"org": "...", ...},
  "target": {"app", "...", "servicesInstance": "..."}
}
```

よって、inputsには以下のような形式で渡せばよいことになります。

```json
[{ 
  "credentials": {"org": "...", ...},
  "target": {"app", "...", "servicesInstance": "..."}
},
{ 
  "credentials": {"org": "...", ...},
  "target": {"app", "...", "servicesInstance": "..."}
}]
```

ただし、"credentials"は実行ごとに変わらないため、毎回同じ値を渡すのは煩雑です。そのような場合、defaultValuesを使うことができます。ここに渡されたパラメータ（オブジェクト型）はコマンドの実行時にinputsに追加して渡されるようになっています。

したがって、マッピングは以下のようになります。

```
inputs <-  [{"target: {"app", "...", "servicesInstance": "..."}}]
defaultValues <- {"credentials": {...}}
```
`$(.execution.input.targets | [{ "target": .[] }] )`という式は、インプットの"targets"という配列を"target"というオブジェクトを持つ配列に変換します。
同様に、`$(.execution.input.credentials | {"credentials": .} )`という式は、インプットの"credentials"オブジェクトを"credentials" というキーを持つオブジェクトに変換しています。

:::note info
Automation Pilotで上記のような動的なパラメータのマッピングを行う場合、[jq](https://jqlang.github.io/jq/manual/v1.6/)と呼ばれるJSON変換のためのコマンドを使用する必要があります。jqについてあまり詳しくなくても、ChatGPTなどを使えばやりたいことをどのようにjqで表現すればよいか教えてくれます。あとはAutomation Pilotの[ドキュメント](https://help.sap.com/docs/automation-pilot/automation-pilot/dynamic-expression)やサンプルコマンドを見ながら、Automation Pilotが受け取れる形にしていけばよいです。

ChatGTPでが以下のようなサンプルを提示したとします。
```:ChatGTPが提示したサンプル
echo '{ "org": "xxx", "space": "xxx" }' | jq '{"credentials": .}'
```
Automation Pilotでは`jq`というキーワードは不要であることと、jqで実施すること全体を`$()`で囲む必要がある、ということを押さえておくとスムーズです。
```:Automation Pilotで設定する式
$(.execution.input.credentials | {"credentials": .} )
```
:::

#### 3.4. アウトプットのマッピング
ForEachコマンドからのアウトプットを、自分で定義したコマンドのアウトプットにマッピングします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4030f4f4-773c-cbcd-f70f-9e079853f998.png)

|キー  |設定値|
|---|---|
|results  |$(.forEach.output.outputs) |

#### 3.5. テスト実行
"Trigger"ボタンでコマンドを実行し、結果を確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/fc3749fd-ad83-1d40-9b85-57cf480e52a9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/b19d3ef2-f904-cf6b-6c13-eabfe59be7e6.png)

アウトプットは以下のように設定されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/cfd9586f-f5e4-00c0-fbba-443ac4be537b.png)

## ここまででできたことと、残る課題
ここまでで、「複数のサービスインスタンスとアプリの組み合わせについて、シークレットを自動で更新する」ことができるようになりました。
残る課題は、更新対象のサービスインスタンスとアプリ（のGUID）の組み合わせをどのように取得するかということです。自動化する場合、想定する流れは以下のようになります。

1. 対象のCloud Foundry組織、またはスペースのすべてのアプリを取得
1. `cf env <APP_NAME>`でアプリにバインドされたサービスインスタンスのGUIDを取得（このとき、XSUAAのみ対象にする、など必要に応じてフィルタリングを行う）
1. アプリとサービスインスタンスのマッピングを作成し、後は本ブログの手順でシークレットを更新する

1.の方法については、事前定義されたコマンド`MassStartCfAppsInOrg`や`MassStartCfAppsInSpace`が参考になりそうです。

もう一つの課題として、本記事の趣旨とはそれますが、マニュアルで作成したサービスキーの更新があります。サービスキーに設定された認証情報がDestinationで使用されている場合、サービスキーの更新とDestinationの設定をセットで行う必要があります。サービスキーとDestinationの割り当て情報はBTPにには存在しないため、台帳管理またはネーミングルールが必要と思われます。

## Appendix
エクスポートしたインプットとコマンドの定義を載せておきます。

### インプット：MyCFCredentials
```json
{
  "id": "MyCatalog-<<<TENANT_ID>>>:MyCFCredentials:1",
  "name": "MyCFCredentials",
  "description": null,
  "catalog": "MyCatalog-<<<TENANT_ID>>>",
  "owner": null,
  "version": 1,
  "keys": {
    "password": {
      "type": "string",
      "sensitive": true,
      "description": "cf user password"
    },
    "org": {
      "type": "string",
      "sensitive": false,
      "description": "CF org guid"
    },
    "region": {
      "type": "string",
      "sensitive": false,
      "description": "example: cf-ap21"
    },
    "user": {
      "type": "string",
      "sensitive": false,
      "description": "cf user"
    },
    "space": {
      "type": "string",
      "sensitive": false,
      "description": "cf space guid"
    }
  },
  "values": {
    "org": "ee5e5a44-67b2-45c2-92f9-7429d7e39a92",
    "region": "cf-ap21",
    "user": "email",
    "space": "46142ba6-2440-4cce-b81a-a2cc136c61d7",
    "password": ""
  },
  "tags": {}
}
```

### コマンド1
```json
{
  "configuration": {
    "values": [],
    "output": {
      "unbind": "$(.unbind.output.binding)",
      "rebind": "$(.rebind.output.binding)",
      "restart": "$(.restart.output.summary)"
    },
    "executors": [
      {
        "execute": "cf-sapcp:UnbindCfServiceInstance:1",
        "input": {
          "app": "$(.execution.input.target.app)",
          "password": "$(.execution.input.credentials.password)",
          "org": "$(.execution.input.credentials.org)",
          "serviceInstance": "$(.execution.input.target.serviceInstance)",
          "region": "$(.execution.input.credentials.region)",
          "user": "$(.execution.input.credentials.user)",
          "space": "$(.execution.input.credentials.space)"
        },
        "alias": "unbind",
        "description": null,
        "progressMessage": null,
        "initialDelay": null,
        "pause": null,
        "when": null,
        "validate": null,
        "autoRetry": null,
        "repeat": null,
        "errorMessages": [],
        "dryRun": null
      },
      {
        "execute": "cf-sapcp:BindCfServiceInstance:1",
        "input": {
          "app": "$(.execution.input.target.app)",
          "password": "$(.execution.input.credentials.password)",
          "org": "$(.execution.input.credentials.org)",
          "serviceInstance": "$(.execution.input.target.serviceInstance)",
          "region": "$(.execution.input.credentials.region)",
          "user": "$(.execution.input.credentials.user)",
          "space": "$(.execution.input.credentials.space)"
        },
        "alias": "rebind",
        "description": null,
        "progressMessage": null,
        "initialDelay": null,
        "pause": null,
        "when": null,
        "validate": null,
        "autoRetry": null,
        "repeat": null,
        "errorMessages": [],
        "dryRun": null
      },
      {
        "execute": "applm-sapcp:RestartCfApp:1",
        "input": {
          "resourceGroup": "$(.execution.input.credentials.space)",
          "password": "$(.execution.input.credentials.password)",
          "resourceName": "$(.execution.input.target.app)",
          "region": "$(.execution.input.credentials.region)",
          "user": "$(.execution.input.credentials.user)",
          "useAdditionalInstance": "true",
          "subAccount": "$(.execution.input.credentials.org)"
        },
        "alias": "restart",
        "description": null,
        "progressMessage": null,
        "initialDelay": null,
        "pause": null,
        "when": null,
        "validate": null,
        "autoRetry": null,
        "repeat": null,
        "errorMessages": [],
        "dryRun": null
      }
    ],
    "listeners": []
  },
  "id": "MyCatalog-<<<TENANT_ID>>>:RoteteBindingSecrets3:1",
  "name": "RoteteBindingSecrets3",
  "description": null,
  "catalog": "MyCatalog-<<<TENANT_ID>>>",
  "version": 1,
  "inputKeys": {
    "credentials": {
      "type": "object",
      "sensitive": false,
      "required": false,
      "minSize": null,
      "maxSize": null,
      "minValue": null,
      "maxValue": null,
      "allowedValues": null,
      "allowedValuesFromInputKeys": null,
      "suggestedValues": null,
      "suggestedValuesFromInputKeys": null,
      "defaultValue": null,
      "defaultValueFromInput": null,
      "description": null
    },
    "target": {
      "type": "object",
      "sensitive": false,
      "required": false,
      "minSize": null,
      "maxSize": null,
      "minValue": null,
      "maxValue": null,
      "allowedValues": null,
      "allowedValuesFromInputKeys": null,
      "suggestedValues": null,
      "suggestedValuesFromInputKeys": null,
      "defaultValue": null,
      "defaultValueFromInput": null,
      "description": "app and serviceInstance guids"
    }
  },
  "outputKeys": {
    "unbind": {
      "type": "string",
      "sensitive": false,
      "description": null
    },
    "rebind": {
      "type": "string",
      "sensitive": false,
      "description": null
    },
    "restart": {
      "type": "string",
      "sensitive": false,
      "description": null
    }
  },
  "tags": {}
}
```
### コマンド2
```json
{
  "configuration": {
    "values": [],
    "output": {
      "results": "$(.forEach.output.outputs)"
    },
    "executors": [
      {
        "execute": "utils-sapcp:ForEach:1",
        "input": {
          "inputs": "$(.execution.input.targets | [{ \"target\": .[] }] )",
          "defaultValues": "$(.execution.input.credentials | {\"credentials\": .} )",
          "command": "MyCatalog-<<<TENANT_ID>>>:RoteteBindingSecrets3:1"
        },
        "alias": "forEach",
        "description": null,
        "progressMessage": null,
        "initialDelay": null,
        "pause": null,
        "when": null,
        "validate": null,
        "autoRetry": null,
        "repeat": null,
        "errorMessages": [],
        "dryRun": null
      }
    ],
    "listeners": []
  },
  "id": "MyCatalog-<<<TENANT_ID>>>:RoteteMultipleBindingSecrets3:1",
  "name": "RoteteMultipleBindingSecrets3",
  "description": null,
  "catalog": "MyCatalog-<<<TENANT_ID>>>",
  "version": 1,
  "inputKeys": {
    "credentials": {
      "type": "object",
      "sensitive": false,
      "required": false,
      "minSize": null,
      "maxSize": null,
      "minValue": null,
      "maxValue": null,
      "allowedValues": null,
      "allowedValuesFromInputKeys": null,
      "suggestedValues": null,
      "suggestedValuesFromInputKeys": null,
      "defaultValue": null,
      "defaultValueFromInput": {
        "inputReference": "MyCatalog-<<<TENANT_ID>>>:MyCFCredentials:1",
        "inputKey": null
      },
      "description": null
    },
    "targets": {
      "type": "array",
      "sensitive": false,
      "required": false,
      "minSize": null,
      "maxSize": null,
      "minValue": null,
      "maxValue": null,
      "allowedValues": null,
      "allowedValuesFromInputKeys": null,
      "suggestedValues": null,
      "suggestedValuesFromInputKeys": null,
      "defaultValue": "[{\"app\": \"8eb22a33-f5e2-4c13-8900-07c986b7478e\", \"serviceInstance\": \"d3ec6781-9818-4e09-bee6-6cbe090c435b\"}]",
      "defaultValueFromInput": null,
      "description": null
    }
  },
  "outputKeys": {
    "results": {
      "type": "array",
      "sensitive": false,
      "description": null
    }
  },
  "tags": {}
}
```