PagerDutyのオーバーライド(Override)とAPIで、祝日のコール順を変更してみる

## 背景
### コール順とPagerDutyの設定
私たちのチームでは、システムのコール連絡にPagerDutyを利用しています。コールの第一通知先は、営業時間内はAチーム、営業時間外はBチームとしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738941/522cc9bd-a280-c36f-2563-f0dce07ae4a5.png)

このため、PagerDuty上ではEscalation Policiesとオンコールスケジュールを以下の設定にしています。

- Escalation Policiesの順番
  1. Aチーム(オンコールスケジュール設定)
  1. Bチーム(固定)
- オンコールスケジュール
  - Restrictionsから営業時間に設定

上記の設定で、営業時間内はオンコールスケジュールにAチームのメンバーが設定されて、それ以外の時間帯はスケジュールが設定されてないため、その次に固定で設定されているBチームが1番コールを受けることができます。

### 祝日のコール順設定の課題
繰り返される日常のコールは先ほどの設定で問題ありませんが、祝日の場合は(当たり前ですが)毎年変わるもので、先ほどの設定では対応できません。

この場合、コール順を正しくするためにスケジュールのOverride機能を利用して代理でコールを受けるBチームのメンバーを指定する必要がありました。
しかし、
- 1日ずつ登録する必要がある　→　日本の祝日は約16日
- 複数のスケジュールで一括登録できない　→　チームのオンコールスケジュールは4つある

 ため、Override設定は最低でも16×4=64回も設定が必要になります。

### 退屈なことはPythonにやらせよう
できるだけ人の手を煩わせずに登録できないかと、PagerDutyのサポートに問い合わせしてみたら
PagerDutyのAPIとoverrides_bulk_operationsというAPIを利用してoverrideを一括登録できるPythonのスクリプトを紹介してくれました。
- PagerDutyのAPIレファレンス
  - https://developer.pagerduty.com/api-reference
- overrides_bulk_operationsスクリプトがある、PagerDutyのサポートスクリプトレポジトリ
  - https://github.com/PagerDuty/public-support-scripts

上記を利用すれば、Overrideの一括登録が可能そうでした。

また、日本の祝日リストを内閣府のページからCSV形式でダウンロードできることがわかりました。
https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html

このCSVと先ほどのスクリプトを組み合わせれば
1. 内閣府のページのCSVから祝日リストを取得
1. リストに登録されている祝日をAPIで一括登録

といった流れで定期的に動作させれば、手動登録なしで祝日のコール順を修正できそうだったので、さっそく作成してみました。

## 作ったもの
定期的な実行のためにGitLabのパイプラインを利用し、2つのスクリプトを作成しました。

### パイプライン
パイプラインの動きは以下のような構成になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738941/ec3cc994-98a9-f478-9b09-de7f2287bd8f.png)

1. 祝日CSVのダウンロード：S3から過去の祝日のCSVをと内閣府のページから最新の祝日CSVをダウンロードします
1. 差分の確認：2つのCSVの祝日を比較し、追加や削除が必要な祝日の差分を出力します
1. Overrideの登録：修正が必要な場合、APIを利用してOverrideの設定を変更します
1. 祝日CSVのアップロード：次の比較のために、今回の祝日CSVをS3にアップロードします


### 祝日の差分確認スクリプト
このスクリプトでは、2つのCSV(過去祝日CSV、最新祝日CSV)を入力でもらい、差分を比較します。
その後、Overrideの削除が必要な祝日(祝日ではなくなった)、追加が必要な祝日(新しく祝日になった)を`holidays_to_delete.csv`と`holidays_to_add.csv`に出力します。

```py:check_holiday_changes.py
import argparse
import csv
from datetime import datetime


def read_holiday(holiday_csv):
    # 祝日CSVの読み込み
    with open(holiday_csv, encoding='shift-jis') as csvfile:
        holiday_list = csv.DictReader(csvfile)

        today = datetime.today()

        this_year_holiday_list = []
        for row in holiday_list:
            holiday = datetime.strptime(row['国民の祝日・休日月日'], '%Y/%m/%d')
            # 今日から未来のものだけ比較対象とする
            if today.date() <= holiday.date():
                this_year_holiday_list.append(holiday)

    return sorted(this_year_holiday_list)


def export_to_change(change_list, export_csv_name):
    with open(export_csv_name, 'w', newline="") as csvfile:
        writer = csv.writer(csvfile)
        for change_holiday in change_list:
            writer.writerow([change_holiday.strftime('%Y-%m-%d')])


def main():
    psr = argparse.ArgumentParser()

    psr.add_argument('--old-csv', required=True, help='Old holiday list')
    psr.add_argument('--new-csv', required=True, help='New holiday list')

    args = psr.parse_args()

    old_csv = args.old_csv
    new_csv = args.new_csv

    old_holiday_list = read_holiday(old_csv)
    new_holiday_list = read_holiday(new_csv)

    # 旧と新の休日を比較
    holidays_to_delete = []
    holidays_to_add = []

    # oldにあって、newにないものはoverrideを消す
    holidays_to_delete = list(set(old_holiday_list) - set(new_holiday_list))
    print('Deleted Holidays')
    print(holidays_to_delete)
    # oldになく、newにあるものはoverrideを追加
    holidays_to_add = list(set(new_holiday_list) - set(old_holiday_list))
    print('Added Holidays')
    print(holidays_to_add)

    export_to_change(holidays_to_delete, 'holidays_to_delete.csv')
    export_to_change(holidays_to_add, 'holidays_to_add.csv')


if __name__ == "__main__":
    main()
```

### Overrideの登録スクリプト
このスクリプトは、祝日の差分確認スクリプトから出力される`holidays_to_delete.csv`と`holidays_to_add.csv`を入力として受け取り、内容が存在する場合にPagerDutyのOverride設定をAPI経由で一括更新します。
API周りの書き方は[overrides_bulk_operations](https://github.com/PagerDuty/public-support-scripts/tree/master/overrides_bulk_operations)を参考にしています。

入力値として必要な情報は以下の通りです。

- PagerDuty APIキー: API操作に必要な認証情報
- holidays_to_add.csv: 追加が必要な祝日情報CSV
- holidays_to_delete.csv: 削除が必要な祝日情報CSV  
- オンコールスケジュールID: Override対象のオンコールスケジュールID
- BチームメンバーのPagerDuty ID: 祝日のOverride時に代理を設定するメンバーのPagerDuty ID

１つのオンコールスケジュールのOverrideには代理を1人設定する必要があります。そのため、BチームのメンバーのPagerDuty ID(メールアドレス)を、ローテーションしながら各オンコールスケジュールに設定しています。そのため、OverrideするオンコールスケジュールのIDとOverride時に入れるBチームのメンバーのIDを入力してもらいます。オンコールスケジュールIDとBチームメンバーのIDは複数指定でき、カンマ区切りで入力できます。


```py:holiday_overrides.py
import argparse
import csv
from datetime import datetime
from dateutil.relativedelta import relativedelta
import pdpyras

# 祝日CSVの読み込み
def read_holiday(holiday_csv):
    with open(holiday_csv, newline="") as csvfile:
        holiday_list = []
        for row in csv.reader(csvfile):
            holiday_list.append(row[0])
    return sorted(holiday_list)

# 削除された祝日をPagerDutyから削除する
def del_override(pd_session, schedule_id_list, del_override_list):
    for holiday in del_override_list:
        holiday = datetime.strptime(holiday, '%Y-%m-%d')
        params = {'since': holiday.strftime(
            '%Y-%m-%d') + ' 00:00:00', 'until': holiday.strftime('%Y-%m-%d') + ' 23:59:59'}

        if holiday > datetime.today():
            for schedule_id in schedule_id_list:
                get_override_response = pd_session.rget(
                    '/schedules/%s/overrides' % schedule_id, params=params)

                if get_override_response:
                    print(
                        f"Deleting override for {holiday} on schedule {schedule_id}")

                    for override in get_override_response:
                        print(f"override id: {override['id']}")
                        try:
                            pd_session.rdelete(
                                '/schedules/%s/overrides/%s' % (schedule_id, override['id']))

                        except pdpyras.PDClientError as e:
                            error = 'Error'
                            if e.response is not None:
                                error = e.response.text
                            print("Could not delete override %s; %s" %
                                  (schedule_id, error))
                            raise
                else:
                    print(
                        f"No override for {holiday} on schedule {schedule_id}")
        else:
            print(f"Skip deleting because {holiday} is a date in the past")


# 追加された祝日をPagerDutyに追加する
def add_override(pd_session, schedule_id_list, mail_list, add_override_list):

    mail_index = 0
    for index, schedule_id in enumerate(schedule_id_list):

        # オーバーライドユーザ取得
        replacement_user = pd_session.find(
            'users', mail_list[mail_index], attribute='email')

        for holiday in add_override_list:
            holiday = datetime.strptime(holiday, '%Y-%m-%d')
            if holiday > datetime.today():
                print(
                    f"Creating override for {schedule_id} on schedule {holiday.date()}")

                next_day = holiday + relativedelta(days=+1)
                try:
                    pd_session.rpost('/schedules/%s/overrides' % schedule_id,
                                     json={
                                         'overrides': [{
                                             'start': holiday.strftime("%Y-%m-%d"),
                                             'end': next_day.strftime("%Y-%m-%d"),
                                             'user': {
                                                 'id': replacement_user['id'],
                                                 'type': 'user_reference'
                                             }
                                         }]
                                     })
                except pdpyras.PDClientError as e:
                    error = 'Error'
                    if e.response is not None:
                        error = e.response.text
                    print("Could not add override %s; %s" %
                          (schedule_id, error))
                    raise
            else:
                print(f"Skip adding because {holiday} is a date in the past")

        if mail_index >= (len(mail_list) - 1):
            mail_index = 0
        else:
            mail_index = mail_index + 1


def main():
    psr = argparse.ArgumentParser()
    psr.add_argument('--api-key', required=True,
                     help='PagerDuty REST API key to use for operations')
    psr.add_argument('--schedule-id', required=True,
                     help='IDs of schedules in which to create overrides')
    psr.add_argument('--mail', required=True,
                     help='Address of the users who is covering the shifts')
    psr.add_argument('--add-holiday-csv', required=True,
                     help='Holiday list to be added')
    psr.add_argument('--del-holiday-csv', required=True,
                     help='Holiday list to be deleted')

    args = psr.parse_args()

    api_key = args.api_key
    schedule_id = args.schedule_id
    mail = args.mail
    add_holiday_csv = args.add_holiday_csv
    del_holiday_csv = args.del_holiday_csv

    print('Load CSV of holiday to be deleted')
    holidays_to_delete = read_holiday(del_holiday_csv)
    print('Load CSV of holidays to be added')
    holidays_to_add = read_holiday(add_holiday_csv)

    print('Change PagerDuty overrides')

    # スケジュールIDとメールを配列変換する
    schedule_id_list = schedule_id.split(',')
    mail_list = mail.split(',')

    print(schedule_id_list)
    print(mail_list)
    # API session 取得
    session = pdpyras.APISession(api_key)
    print("Session created")

    if holidays_to_delete:
        print("Delete overrides")
        del_override(session, schedule_id_list, holidays_to_delete)
    else:
        print('No need to delete PagerDuty overrides')

    if holidays_to_add:
        print("Add overrides")
        add_override(session, schedule_id_list,
                     mail_list, holidays_to_add)
    else:
        print('No need to add PagerDuty overrides')

    print('Finsh to change overrides')


if __name__ == "__main__":
    main()

```

### 結果
GitLabからパイプラインを動作させて、4月のオンコールスケジュールを確認してみました。
4月後半のゴールデンウィークの祝日がちゃんとオーバーライドされてますね！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738941/1a1189bd-2666-c426-76d5-8ca822694363.png)

## まとめ
 PagerDutyのAPIとOverride機能を利用したスクリプトにより、日本の祝日に正しいコール順で設定することができました。しかしながら、以下のような改善もできると考えています。

1. Override設定時に個別に代理を指定する必要があるため、Bチームメンバーの変更があった際には設定済みのOverrideも修正が必要
1. 祝日だけではなく、outlookなどから休暇情報を読み取って、設定できるようにしたい

今後時間があれば上記の点を含めて、より柔軟な設定できる方法を工夫してみたいと思います。

### 余談、スクリプト余命はあと1年...?
ネットで関連情報を検索している中、PagerDutyフォーラムの質問から2024年のロードマップに祝日に関する機能を追加する予定とのコメントがありました。(遅延中との最新コメントがありますが)

(2024.04.23 追記 →　フォーラムがリニューアル中で質問が見えなくなっています)
https://community.pagerduty.com/forum/t/pagerduty-to-exclude-alerts-on-public-holidays-in-2-contries-differently/2436/7

この情報をスクリプト開発後半で知ってしまったので少し悲しくなりましたが(笑)、PagerDutyで直接設定できることはうれしいですね。リリースされる日を楽しみに待ってます！