GAS+slack+GitLab Runners API+bash+LaunchtclでGitLab Runnerの監視プログラムを組む

GitLab Runnerが音もなく死んでいる(offline状態?)になっていることがたびたび起きたので、定期的にRunnerの状態をチェックして死んでたら教えてくれるslack appを作成した。

# 前提条件
GitLabはアクセスは社内LANからの物しか受け付けないようになっている。
したがってRunnerの状態のチェックだけは必ず社内のPCからしなくてはならない。
# 使用機材
iMac (OS ver : macOS mojave 10.14.4)
# ざっくりとした構成
GAS : 監視対象のRunner一覧をspreadSheetで記録。スクリプトをウェブアプリケーションとして公開しておき、httpアクセスで追加・削除・登録されているもの一覧の確認をできるようにする

bash : GASから監視対象一覧を取得。ひとつひとつRunners APIをたたいてRunnerのstateを確認。死んでたらそれぞれのRunnerの管理人にslackでメッセージを送信。

mac : 上記のbashスクリプトをlaunchctlコマンドで15分ごとに定期実行

slack : appを作成。Runnerが死んでいたらbashがこのappとしてそのRunnerの管理人にメッセージを送る。また、スラッシュコマンドでGASにアクセスしてRunnerの追加・削除・登録されているもの一覧の取得ができるようにする。

# それぞれのコード
## GAS
A1に「id」B1に「管理人」とそれぞれ項目名を書いたspreadSheetを用意し、以下のようなスクリプトを作成し、ウェブアプリケーションとして公開。「管理人」はそのidを監視対象に追加した人と判断する。

```javascript

// スラッシュコマンド用
// eとしては以下のような形のjsonが来る。
//{
//    "parameter": {
//        "channel_name": "hogehoge",
//        "user_id": "A0B1C2DE",
//        "user_name": "kkkkan",
//        "trigger_id": "012345678.abcdefg",
//        "team_domain": "××-jp",
//        "team_id": "ABCABC012012",
//        "text": "0",
//        "channel_id": "AAAAAAA",
//        "command": "/add_id",
//        "token": "abc456DEF0123",
//        "response_url": "https://hooks.slack.com/commands/××/●●/▼▼"
//    },
//    "contextPath": "",
//    "contentLength": 100,
//    "queryString": "",
//    "parameters": {
//        "channel_name": [
//            "hogehoge"
//        ],
//        "user_id": [
//            "A0B1C2DE"
//        ],
//        "user_name": [
//            "kkkkan"
//        ],
//        "trigger_id": [
//            "012345678.abcdefg"
//        ],
//        "team_domain": [
//            "××-jp"
//        ],
//        "team_id": [
//            "ABCABC012012"
//        ],
//        "text": [
//            "0"
//        ],
//        "channel_id": [
//            "AAAAAAA"
//        ],
//        "command": [
//            "/add_id"
//        ],
//        "token": [
//            "abc456DEF0123"
//        ],
//        "response_url": [
//            "https://hooks.slack.com/commands/××/●●/▼▼"
//        ]
//    },
//    "postData": {
//        "type": "application/x-www-form-urlencoded",
//        "length": 357,
//        "contents": "token=abc456DEF0123&team_id=ABCABC012012&team_domain=××-jp&channel_id=AAAAAAA&channel_name=hogehoge&user_id=U4TCA3K8S&user_name=A0B1C2DE&command=%2Fadd_id&text=0&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2××%2●●%2▼▼&trigger_id=ABCABC012012",
//        "name": "postData"
//    }
//}
function doPost(e) {
  // jsonをdecode
  var json_str=JSON.stringify(e,null, "\t");
  //  return ContentService.createTextOutput(json_str).setMimeType(ContentService.MimeType.JSON);
  // jsonを再度encode
  var json=JSON.parse(json_str);
  // コマンドを打った人
  var user_name=json.parameter.user_name;
  // コマンドに渡した文字列
  var texts=json.parameter.text.split(' ');
  // コマンド名
  var command=json.parameter.command;
  // spread sheet
  var spreadsheet = SpreadsheetApp.openById('【spreadSheetのID】');
  // 1枚目のシート
  var sheet = spreadsheet.getSheets()[0];
  
  var body = "";
  if(command == "/add_runner"){
    // 追加コマンドだったら
    var id = texts[0];
    if(texts.length==1 && id != null && id != ''&& !isNaN(id) ){
      // コマンドが受け付けるメッセージは1つの数字だけ
      var startrow = 2;
      var startcol = 1;
      var lastrow = sheet.getLastRow();
      var lastcol = sheet.getLastColumn();
      
      //がさっと取得
      var sheetdata = sheet.getSheetValues(startrow, startcol, lastrow, lastcol);
      for(var i=0;i < lastrow -1 ;i++){
        if(sheetdata[i][0] == id && sheetdata[i][1] == user_name){
          // もしすでにidも管理者も同じRunnerが追加済みだったら
          body = body + user_name+"の管理する Id "+texts[0]+"のRunnerは既に監視対象です。\n/runner_listコマンドで現在登録されているRunnerの一覧が確認できます。";
        }
      }
      
      if(body == ""){
        // 新規追加
        // 監視対象に追加
        var arrData = sheet.getDataRange().getValues();
        arrData.push([id,user_name]);
        var rows = arrData.length;
        var cols = arrData[0].length;
        sheet.getRange(1,1,rows,cols).setValues(arrData);
        
        // 表示するメッセージ
        body = body + "Runner Id "+texts[0]+"を監視対象に追加しました。";
      }
    }else{
      // メッセージ内容が間違っていたら
      body = body + "メッセージが間違っています。/add_runner [Runner Id] で監視対象に追加できます。";
    }
  }else if(command == "/runner_list"){
    // 登録してあるreunner一覧表示コマンドだったら
    var list_str = getList();
    // 見やすいよう整形
    body = JSON.stringify(JSON.parse(list_str),null, "\t");
  }else if(command == "/remove_runner"){
    // 削除コマンドだったら
    var id = texts[0];
    if(texts.length==1 && id != null && id != ''&& !isNaN(id) ){
      // コマンドが受け付けるメッセージは1つの数字だけ
      
      var startrow = 2;
      var startcol = 1;
      var lastrow = sheet.getLastRow();
      var lastcol = sheet.getLastColumn();
      
      //がさっと取得
      var sheetdata = sheet.getSheetValues(startrow, startcol, lastrow, lastcol);
      var body = "";
      
      for(var i =0 ;i < lastrow -1 ;i++){
        if(sheetdata[i][0] == id && sheetdata[i][1] == user_name){
          // そのidの、その人が管理するrunnerが監視対象に含まれていたら
          // 削除
          sheet.getRange(startrow+i,1, 1, 2).clear();
          // 空白になってしまった行を削除
          sheet.deleteRow(startrow+i)
          // 表示するメッセージ
          body = body + user_name+"の管理する Id "+texts[0]+"のRunnerを監視対象から削除しました。";
        }
      }
      
      // 表示するメッセージ
      // もし何も削除しなかったら
      if(body == ""){
        body = body +user_name+"の管理する Id "+texts[0]+"のRunnerは監視対象に含まれていませんでした。\n/runner_listコマンドで現在登録されているRunnerの一覧が確認できます。";
      }
      
    }else{
      // メッセージ内容が間違っていたら
      body = body + "メッセージが間違っています。/remove_runner [Runner Id] で監視対象から削除できます。";
    }
  }else{
    body = body + "false";
  }
  return ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
}

// 監視スクリプトが呼ぶ用のメソッド
// 監視するべきrunnerのidと管理人を返す
function doGet() {
  return ContentService.createTextOutput(getList()).setMimeType(ContentService.MimeType.JSON);
}


// 監視するべきrunnerのidと管理人を返す
function getList(){
  // spread sheet
  var spreadsheet = SpreadsheetApp.openById('【spreadSheetのID】');
  // 1枚目のシート
  var sheet = spreadsheet.getSheets()[0];
  
  var startrow = 2;
  var startcol = 1;
  var lastrow = sheet.getLastRow();
  var lastcol = sheet.getLastColumn();
  
  //がさっと取得
  var sheetdata = sheet.getSheetValues(startrow, startcol, lastrow, lastcol);
  var body = "[";
  
  for(var i =0 ;i < lastrow -1 ;i++){
    body = body + "{ \"id\":\""+sheetdata[i][0]+"\", \"admin\":\""+sheetdata[i][1]+"\"}";
    if(i != lastrow -2){
      body = body +",";
    }
  }
  // 返すデータがないもないときにも空配列を返せるように]はfor文の外で。
  body = body + "]";
  
  Logger.log(body); 
  
  return body;
  
}
```

## bashスクリプト

```check_gitlab_runners.sh

GitLabのRunners API(https://docs.gitlab.com/ee/api/runners.html)とSlackのmessage API(https://api.slack.com/methods/chat.postMessage)を使用する。

#!/usr/bin/env bash
cd /Users/kkkkan/Desktop/gitlab_runner_watcher/
# 監視するgitlab runnerのidと管理人の配列を取得
check_list=$(curl -L 【公開したGASウェブアプリケーションのURL】 | jq )
list_len=$(echo $check_list | jq length)

# tokenと投稿するchannel
slack_token="【xoxb-から始まる、slack appのOAuth Access Token】"
at_mark="@"


# 監視する状態
offline='"offline"'
paused='"paused"'
#echo ${status}

#set -x

for i in $( seq 0 $(($list_len -1 ))) 
do
    id=$(echo $check_list | jq -r .[$i].id)
    admin=$(echo $check_list | jq -r .[$i].admin)
#for id in "${check_runner_ids[@]}"
#do
    echo ${id}
    http_result=$(curl -s --header "PRIVATE-TOKEN: 【GitLabのtoken】" "http://【監視したいGitLabインスタンスのドメイン】/api/v4/runners/${id}" | jq )
    #echo "$http_result"
    touch result.json
    echo "$http_result" > result.json
    #cat result.json
    # messageを取得
    # 権限などで正常に取れない場合だけmessgeがあるっぽい
    message=$(jq ".message" result.json)
    #set -x
    #echo "$message"
    if [ "$message" != "null" ]
    then
      payload="GitLab Runner $_runner_name (ID : $id)の状態は取得できません。/remove_runnerコマンドで監視対象から外すか、このRunnerをkkkkanがみれるようにGitLab上から権限を変更してください。"
      curl -X POST -d "token="$slack_token"&channel="$at_mark""$admin"&text=""$payload" "https://slack.com/api/chat.postMessage"
    fi
    # statusを取得
    status=$(jq ".status" result.json)
    _runner_name=$(jq ".description" result.json)
    runner_name=$(eval echo ${_runner_name} )

    if [ "$status" = "$offline" ]
    then
       #echo "offline$id"
       payload="GitLab Runner $_runner_name (ID : $id)のステータスがofflineになっています。"
       #set -x
      
       # Runnerの管理人にメッセージをポスト
       curl -X POST -d "token="$slack_token"&channel="$at_mark""$admin"&text=""$payload" "https://slack.com/api/chat.postMessage"
       
    fi

    if [ "$status" = "$paused" ]
    then
       #echo "pause$id"
       payload="GitLab Runner $_runner_name (ID : $id)のステータスがpauseになっています。"
       #set -x
       
       # Runnerの管理人にメッセージをポスト
       curl -X POST -d "token="$slack_token"&channel="$at_mark""$admin"&text=""$payload" "https://slack.com/api/chat.postMessage"
    fi
done
```

## Launchctl
 launchctlのplistファイル。~/Library/LaunchAgents/以下に置く。(15分ごとに実行するようにしている。)

```gitlabrunner.watcher.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
 <key>Label</key>
   <string>gitlabrunner.watcher</string>
  <!--フルパスで指定-->
 <key>ProgramArguments</key>
   <array>
     <string>/Users/kkkkan/Desktop/gitlab_runner_watcher/check_gitlab_runners.sh</string>
   </array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
    <string>/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin:/usr/local/sbin</string>
</dict>
<key>StandardErrorPath</key>
    <string>/Users/kkkkan/Desktop/error.log</string>
    <key>StandardOutPath</key>
    <string>/Users/kkkkan/Desktop/out.log</string>
 <key>StartInterval</key>
<!--15分毎-->
   <integer>900</integer>
 <key>RunAtLoad</key>
   <true/>
 <key>ExitTimeout</key>
   <integer>300</integer>
</dict>
</plist>
```


## slack
https://api.slack.com/apps
から新しくappを作って、slash commandsを導入し、/add_runner,/remove_runner,/runner_listコマンドを作成。全てRequest URLは先ほど作って公開したGAS webアプリケーションのURLにする。


# ハマったところ
- launchtclが全然実行されない! 
→

```plist

<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
    <string>/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin:/usr/local/sbin</string>
</dict>
```
最初plistにこれを追加していなかったのだが、そうしたらpathが一部上手く通っていなかったみたいでjqコマンドが見つからなくてエラーしていた。今回ここが一番ドはまりして時間がかかった。

# 感想など
- GitLab Runnerが死んだら通知が来るような機構はGitLab CIが備えていても良いようなものだが、見つけられなかったので自前で実装した。が、自分の探し方が下手で見つけられなかっただけの可能性が多分にあるのでもし見つけた方がいたら教えてほしいです。
- 前提条件に書いたように、GitLabアクセスが社内PCからしかできない環境のためこの構成にしたが、その制約がないならslackへのメッセージ送信もGASスクリプト内で行い、GASウェブアプリケーションの定期実行機能を用いることでGAS+slack+GitLab Runners APIだけで十分実現可能だと思う。

# 参考にしたもの
- [slack botの作成方法](https://qiita.com/yuukiw00w/items/94e4495fc593cfbda45c)
- [launchctlのpathについて](https://scrapbox.io/shokai/Sierra%E3%81%A7launchd%E3%81%ABpath%E3%81%8C%E9%80%9A%E3%82%89%E3%81%AA%E3%81%84)
- [GASのspreadSheet内の数値の削除方法](https://tonari-it.com/gas-spreadsheet-range-clear-clearcontent/)
- [GASのspreadSheetへの書き込み方法](https://tonari-it.com/gas-setvalue/)
- [GASのspreadSheetへの書き込み方法](https://qiita.com/chihiro/items/20a54865c15966e807ee)
- [GASのspreadSheetの読み込み方法](https://qiita.com/nurburg/items/744ec53477f4ae328555)
- [GASのspreadSheetの行の削除方法](https://www.atmarkit.co.jp/ait/articles/1706/09/news018_2.html)