Rundeck をウェブアプリケーションのバックエンドに組み込む

#はじめに
ウェブアプリケーションで実行するには時間がかかりすぎる処理は、cronやバッチ処理などの非同期処理と分離することがあります。cronにしてしまうとアプリケーションからコントロールが効かず、待つしかない状況に陥ります。
Rundeckに重たい処理を置いて単独で動く状況を用意しつつ、ウェブアプリケーションからRundeckのAPIを使いRundeck上のジョブを制御してみます。

#使ったもの
rundeck-3.0.6-20180917.war (Open source windows版)


#RundeckのAPIを使えるようにするまで
Rundeckは標準でRESTなAPIを装備しています。簡単な設定をするだけで使えるようになります。

##API tokenを発行する
Rundeckにログインし右上のアイコンProfileから設定する。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/e5b0b9fd-b4c5-9b85-eb86-7c53078350cb.png)

＋マークで新しいAPI Tokenを発行画面へ行き
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/f3bcd911-282c-b793-fffc-6ed0e888c4e8.png)

必要項目を入れてAPI Tokenの発行をする
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/5677016e-ec8a-b5f0-c923-7ba6f8329632.png)


:warning: API tokenの有効期限を変更する
インストール直後の状態だとAPIのトークンは最大で30日間しか有効にできません。システムに組み込むには不便なので、30日以上指定可能にしたい場合には、以下の設定をしておきます。


```rundeck-config.properties
rundeck.api.tokens.duration.max=1y
```

0にすると無制限になりますが、今回は1y(1年間)にしておきました。

>Duration string indicating maximum lifetime of API Tokens. If unset, the value will be "30d" (30 days). Format: "##{ydhms}" (years, days, hours, minutes, seconds). If you want to disable the max expiration you can set it to 0 and create token with 0 duration that don't expire.

https://rundeck.org/2.9.4/administration/configuration-file-reference.html#security

設定を変更しRundeckを再起動すると、365dまで設定できるようになっています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/04a99368-9150-c728-fec6-173c63eec016.png)

ここまでで前準備完了です。API Tokenはメモしておきます。

```API_token.
triEfS4Qjc3ymqG8plKqPvxBKE7rtJxe
```


#APIにアクセスしてみる
RundeckのAPIは、```http://hostname:4440/api/Version/``` でアクセスできます。呼び出しが簡単なsystem/infoにアクセスして動作確認してみます。hostnameとport番号は環境に合わせて書き換えてください。

**URL**
http\://hostname:4440/api/2/system/info?authtoken=triEfS4Qjc3ymqG8plKqPvxBKE7rtJxe

API Tokenはパラメータに付ける形か、ヘッダーに含める形かどちらかです。
https://rundeck.org/docs/api/index.html#authentication

ブラウザでアクセスしてみて、XMLのメッセージがつらつらと返ってくればOKです。

:warning:
RundeckにログインしているブラウザでAPIの確認するとNot Foundの画面が出ます。この場合はRundeckにログインしていない別のブラウザを使うか、コマンドラインベースのツール(curl, wgetなど)で確認してください。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/228c8a17-ce9a-1b28-857f-c07ea79d5461.png)

APIにアクセスできる環境が整いました。

#Rundeckをアプリケーションに組み込む準備
ここから本題。
オンラインで時間のかかる処理をバックエンドのRundeckに依頼し、結果を待つアプリケーションを想定します。

**やること**
1. Rundeckにジョブを作成(GUI)
2. ジョブが次にいつ動くのか確認(API)
3. Rundeck APIでジョブの状態確認(API)
4. Rundeck APIでジョブの起動(API)


##jobを作成
RundeckのGUIにログインしジョブを作ります。ジョブ自体もAPIで作れますが、設定済みのジョブをウェブアプリケーションと連携することに絞ります。今回は、時間のかかるジョブをエミュレートするだけなので、「30秒waitして正常終了するジョブ」を作成しました。

Power Shellのsleepを呼ぶだけ
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/baba73c5-1feb-b9de-b51a-93a439bad0b6.png)

30分に1回動くように設定
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/bc863845-a0bc-c03e-0e0c-2d0663ab43a4.png)

多重起動は禁止
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/f80544c0-bd18-1d40-cc9d-a8230a2f7375.png)

UUIDは最下部にあります。一度ジョブ作成を完了させた後にジョブ編集すると見えます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/c326160b-ee03-6022-70ec-39a5ad02632c.png)


```UUID.
98d2c286-3165-403a-b2c0-90dddc3f2205
```

##次に動く時間の確認
次回ジョブが動く時間をアプリケーション側から見えるようにします。時間が見えていれば待つべきか手動実行するべきか判断付きやすくなります。特定のジョブがRundeck側でいつ動くのか調べるには、```job/info```を使います。

```
curl --verbose 
  -H 'Accept:application/json ' 
  -H 'X-Rundeck-Auth-Token:triEfS4Qjc3ymqG8plKqPvxBKE7rtJxe'  
  http://hostname:4440/api/27/job/98d2c286-3165-403a-b2c0-90dddc3f2205/info
```
実際は1行で書きます

```json
{
"href":"http://hostname:4440//api/27/job/98d2c286-3165-403a-b2c0-90dddc3f2205",
"averageDuration":21872,
"id":"98d2c286-3165-403a-b2c0-90dddc3f2205",
"scheduleEnabled":true,
"scheduled":true,
"enabled":true,
"nextScheduledExecution":"2018-09-29T16:30:00Z",
"permalink":"http://hostname:4440//project/API_test/job/show/98d2c286-3165-403a-b2c0-90dddc3f2205",
"group":null,
"description":"",
"project":"API_test",
"name":"job 30 second"
}
```

次回実行時刻は、```nextScheduledExecution```に入っています。

https://rundeck.org/docs/api/index.html#get-job-metadata

##今、ジョブが動いているか確認
アプリケーション側からジョブを動かす前に、そのジョブが動いているか確認する必要があります。複数人でブラウザを見ているケースや、Rundeckのスケジューラ側の起動とのバッティングを想定しています。

jobのUUIDを指定して```executions```のAPIを実行すると取得できます。動いているジョブがあるか確認したいだけなのでstatus=runningで絞り込りをします。

```
curl
 -H 'Accept:application/json '
 -H 'X-Rundeck-Auth-Token:triEfS4Qjc3ymqG8plKqPvxBKE7rtJxe' 
 http://hostname:4440/api/27/job/98d2c286-3165-403a-b2c0-90dddc3f2205/executions?status=running
```

**ジョブが動いていないときレスポンス**

```json
{
"paging":{"count":0,"total":0,"offset":0,"max":20},
"executions":[]
}
```

**ジョブが動いているときレスポンス**

```json
{
"paging":{"count":1, "total":1, "offset":0,"max":20},
"executions":[{
  "id":45,
  "href":
  "http://hostname:4440//api/27/execution/45",
  "permalink":"http://hostname:4440//project/API_test/execution/show/45",
  "status":"running",
  "project":"API_test",
  "executionType":"user",
  "user":"admin",
  "date-started":{"unixtime":1538237400000,"date":"2018-09-29T16:10:00Z"},
  "job":{
    "id":"98d2c286-3165-403a-b2c0-90dddc3f2205",
    "averageDuration":21872,
    "name":"job 30 second",
    "group":"",
    "project":"API_test",
    "description":"",
    "href":"http://hostname:4440//api/27/job/98d2c286-3165-403a-b2c0-90dddc3f2205",
    "permalink":"http://hostname:4440//project/API_test/job/show/98d2c286-3165-403a-b2c0-90dddc3f2205"
  },
  "description":"powershell sleep 30",
  "argstring":null
}]
}
```

このAPIを叩いて、countが0か確認すればよさそうです。

https://rundeck.org/docs/api/index.html#getting-executions-for-a-job

##ジョブの起動
ウェブアプリケーション側からバッチジョブを実行したいときに、```job/run```を実行します。

```
curl
 -H 'Accept:application/json '
 -H 'X-Rundeck-Auth-Token:triEfS4Qjc3ymqG8plKqPvxBKE7rtJxe' 
 -X POST
 http://hostname:4440/api/27/job/98d2c286-3165-403a-b2c0-90dddc3f2205/run
```

jobのrun はPOSTなので注意です。


**起動に成功したときのレスポンス**

```json
{
"id":46,
"href":"http://hostname:4440//api/27/execution/46",
"permalink":"http://hostname:4440//project/API_test/execution/show/46",
"status":"running",
"project":"API_test",
"executionType":"user",
"user":"admin from api",
"date-started":{"unixtime":1538237862120,"date":"2018-09-29T16:17:42Z"},
"job":{
  "id":"98d2c286-3165-403a-b2c0-90dddc3f2205",
  "averageDuration":22907,
  "name":"job 30 second",
  "group":"",
  "project":"API_test",
  "description":"",
  "href":"http://hostname:4440//api/27/job/98d2c286-3165-403a-b2c0-90dddc3f2205",
  "permalink":"http://hostname:4440//project/API_test/job/show/98d2c286-3165-403a-b2c0-90dddc3f2205"
},
"description":"powershell sleep 30",
"argstring":null
}
```

**起動に失敗した時のレスポンス**

```json
{
"error":true,
"apiversion":27,
"errorCode":"api.error.execution.conflict",
"message":"Execution had a conflict: Job \"job 30 second\" {{Job 98d2c286-3165-403a-b2c0-90dddc3f2205}} is currently being executed {{Execution 46}}"
}
```

https://rundeck.org/docs/api/index.html#running-a-job

#アプリケーションに組み込む
HTMLで画面を作り、jQueryでRundeckのAPIを実行するものを作ってみました。

##やっていること
10秒に一度、RundeckのAPIを実行し、次のジョブ実行時間、最終のジョブ実行時間、今動いているかを取ってくる。状態は画面に表示する。
ステータス確認ボタンは、10秒間隔で実行しているチェックを手動で実行するだけ。
ジョブ実行ボタンは、Rundeck側のジョブを呼び出し、時間のかかる処理をバックグラウンドで実行する
Rundeckのスケジュール時間まで5分を切ると、手動実行はできないようにする。

状態によって、ボタンの色（disabled）とアイコンが変わります。

**平常時は手動実行も可能**
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/5c9760f6-7057-802a-bb58-e7cdbc3fb1e3.png)


**実行中はボタンを押せない**
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/7ec07eaa-4d54-df46-9283-87413bf668a9.png)


**スケジュール実行まで5分切ると手動実行は禁止**
![image.png](https://qiita-image-store.s3.amazonaws.com/0/168315/bbbdbf50-6938-b890-7d95-2e0ed108ab77.png)


##コード
:warning: Rundeck APIのURLとAuthKeyがHTML上に露出していますが、ウェブアプリケーション経由でRundeckにアクセスする（上手に隠す）のが正しい設計です。

```html:rundeck-api.html
<html>
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous">
        <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
    </head>
    <body>
        <div style='margin:20px'>
        <p>次のジョブ実行時間：<span id="next_execution">-</span></p>
        <p>最後のジョブ実行時間：<span id="last_execution">-</span></p>
        <p>チェック時間：<span id="last_check">-</span></p>
        <button id="b2" type="button" class="btn btn-primary" onclick="check()">ステータス確認</button>
        <button id="b3" type="button" class="btn btn-primary" onclick="executeJob()">
            <i class="far fa-play-circle" id="available" style='display:none'></i>
            <i class="far fa-pause-circle" id="not_available" style='display:none'></i>
            <i class="fas fa-sync fa-spin" id="running" style='display:none'></i> ジョブ実行</button>
        </div>
    </body>

<script>
    var jobid='98d2c286-3165-403a-b2c0-90dddc3f2205';
    var apitoken='triEfS4Qjc3ymqG8plKqPvxBKE7rtJxe';
    var apiheader = {'X-Rundeck-Auth-Token':apitoken,'Accept':'application/json'};

    var running = false;
    var shceduled = true;
    
    var next_execution_time = 0;
    var last_execution_time = 0;
    var last_check_time = 0;

    var job_execution_limit = 5*60; //sec
    var job_interval = 0;
    var auto_check_interval = 1*60; //sleep(sec)

    function check(){
        //非同期処理を順番に
        checkNextExecution().then(checkJobStatus).then(checkLastExecution).then(setButtonStatus);
    }

    function checkNextExecution(){
        dfd = new $.Deferred;

        //jobがスケジュールされない状況はこれ以上やらない。
        if( !shceduled ){
            return dfd.resolve();
        }

        //rundeckの次回起動時間が来ていなければチェックしない
        last_check_time = Date.now();
        if( next_execution_time > last_check_time ){
            return dfd.resolve();
        } 

        //rundeck APIで次の動作時間を調べる
        $.ajax({
            url:'/rundeck-api/27/job/' + jobid + '/info',
            type:'GET',
            dataType:'json',
            data:{},
            headers:apiheader
        })
        .done( (data) => {
            //APIから次実行時間が取れたときだけ、現在時間との時間差を計算
            if( data.nextScheduledExecution){
                scheduled=true;
                d = new Date(data.nextScheduledExecution);
                next_execution_time = d.getTime();
                console.log('next=%s, now=%s', next_execution_time, last_check_time);
                job_interval = (next_execution_time-last_check_time)/1000;
            }else{
                //取れないときは、スケジュールされていないので二度とチェックしないようにfalse
                scheduled=false;
            }
            dfd.resolve();
        })
        .fail( (data) => {
            console.log(data);
            dfd.resolve();
        })
        .always( (data) => {
        });
        return dfd.promise();
    }

    function checkJobStatus(){
        dfd = new $.Deferred;
    
        $.ajax({
            url:'/rundeck-api/27/job/' + jobid + '/executions',
            type:'GET',
            dataType:'json',
            data:{'status':'running'},
            headers:apiheader
        })
        .done( (data) => {
            // console.log(data);

            //status=runningで1行取れれば動いてるとみなす
            running =  data.paging.count>0;
            dfd.resolve();
        })
        .fail( (data) => {
            console.log(data);
            dfd.resolve();
        })
        .always( (data) => {
        });
        return dfd.promise();
    }

    function checkLastExecution(){
        dfd = new $.Deferred;
    
        //executionsのAPIは新しい順に返してくる。並び順指定するパラメータはない
        $.ajax({
            url:'/rundeck-api/27/job/' + jobid + '/executions',
            type:'GET',
            dataType:'json',
            data:{'max':'1'},
            headers:apiheader
        })
        .done( (data) => {
            //取れたら1個目が最終起動ログになるはず
            last_execution_time = data.executions[0]['date-started'].unixtime;
            dfd.resolve();
        })
        .fail( (data) => {
            console.log(data);
            dfd.resolve();
        })
        .always( (data) => {
        });
        return dfd.promise();
    }

    //ジョブの手動実行
    function executeJob(){
        $.ajax({
            url:'/rundeck-api/27/job/' + jobid + '/run',
            type:'POST',
            dataType:'json',
            data:{},
            headers:apiheader
        })
        .done( (data) => {
            console.log(data);
            running = true;
            setButtonStatus();
        })
        .fail( (data) => {
            console.log(data);
        })
        .always( (data) => {
        });
    }

    //画面のステータスを変更する関数
    function setButtonStatus(){
        console.log('scheduled=%s, interval=%s, limit=%s, running=%s',scheduled,job_interval,job_execution_limit,running);
        
        next_date = getFormattedDate(next_execution_time);
        last_date = getFormattedDate(last_execution_time);
        last_check = getFormattedDate(last_check_time);
        $('span#next_execution').text(next_date);
        $('span#last_execution').text(last_date);
        $('span#last_check').text(last_check);
        
        if( running ){
            $('i#available').hide();
            $('i#not_available').hide();
            $('i#running').show();
            $('button#b3').prop('disabled', true);
        }else{
            $('i#running').hide();

            if( scheduled && job_interval > job_execution_limit ){
                $('i#available').hide();
                $('i#not_available').show();
                $('button#b3').prop('disabled', true);

            }else{
                $('i#available').show();
                $('i#not_available').hide();
                $('button#b3').prop('disabled', false);
            }
        }
    }

    function getFormattedDate(time){
        if( time == 0){
            return '--';
        }
        date = new Date(time);
        y = date.getFullYear();
        m = padZ(date.getMonth()+1);
        d = padZ(date.getDate());
        h = padZ(date.getHours());
        mi = padZ(date.getMinutes());
        s = padZ(date.getSeconds());
        return y + '/' + m + '/' + d + ' ' + h + ':' + mi + ':' + s;
    }
    function padZ(a){
        if( a < 10 ){
            return '0' + a;
        }
        return a;
    }

    $(function(){
        check();
        setInterval(check,auto_check_interval*1000);
    });
</script>
</html>
```

##おまけ
javascriptでRundeck APIへアクセスすると、CORSの設定がないためAPI接続エラーになります。Rundeckにbuild-inされているWebサーバーが```Access-Control-Allow-Origin```ヘッダーを返していないので、ブラウザがRundeckへのリクエストを止めてしまいます。ドキュメントを探してみたのですが、Access-Control-Allow-Originヘッダーを追加で設定するような項目は見つかりませんでした。
今回は、HTMLを配信したホストの/rundeck-api/以下にAPIのリクエストを送り、ApacheからProxyしてRundeckのポート付きのEndpointに転送する形で凌ぎました。

```apache:rundeck-api.conf
ProxyPass /rundeck-api/ http://hostname:4440/api/
```

Rundeckのサーバーがインターネット側からアクセス可能になっていることは少ないと思います。サーバーの位置やAPIのトークンが見えないように、アプリケーションとRundeckの間にプロキシ役のアプリケーションを置くのが正しい選択です。

#まとめ
今回は、バックエンドにRundeckを置きウェブアプリアプリケーションの一部として組み込むサンプルを作りました。ウェブとバッチで処理を分離するとき、バッチの状態を管理するテーブルを作ったり、同時起動しないように配慮したりと、いろいろと頭を悩ます部分があると思いますが、ひとつの解決法になるのではないでしょうか。