2015年3月22日日曜日

別サーバにあるJenkinsジョブと連携するスクリプト書いた

タイトルの通り、別サーバにあるJenkins間でジョブを連携させるスクリプトを勉強がてら書いたので、ことのついでに晒してみます。

ソースはgist(syncbuild_remote_jenkins.py)にあります。
動作環境などは↓のような感じ。
  • python2.7で動作確認済み
    • 3.x系では動きません
  • 基本的に標準のライブラリだけで動く(はず)
    • 自由にpip install出来ない環境では重宝するかも
使い方としては、任意のホスト、ポート、ジョブ名を指定する感じです。
$ ./syncbuild_remote_jenkins.py --host remote-jenkins-server --job RemoteJobName --interval 300

ちなみに、intervalオプションでジョブの状態をポーリングする間隔を変えられるようにしています。(上記の例だと300秒)
時間のかかるジョブであれば長めに設定すると無駄な処理が走らなくて良いかと。

できること/できないこと

今のところ以下のことが可能です。
  • 指定したサーバのJenkinsジョブが完了するまで待機して、成否などの情報を返す

一方、できないことは以下の通りです。
  • 認証付きのJenkinsサーバには対応していません
  • このスクリプトでビルドしたジョブの成果物を取得するとかも特にやってません
  • その他いろいろ未対応

いやそもそもさぁ……

Q:普通に Remote access API とか Parameterized Remote Trigger Plugin とかあるよね?
A:うん。

確かに便利なプラグインやライブラリは存在していて、最初はRemote Trigger Pluginを使おうと考えていました。
が、こいつの出力があまり充実しておらず、特にビルドした別サーバのJenkinsジョブのURLが出てこないのがちょっとマズかったので自作することにしました。
(設定いじると出てくるのであれば乗り換えたいですが。)

自作するにしても、なるべく簡単に作りたかったので単にRemote access APIをシェルから叩くつもりでしたが、レスポンスのパースとかが面倒くさそうだった大変そうだったのでPythonで実装しています。

実装方針とか

そもそも複数サーバのJenkinsを連携させようと考えたきっかけが、会社で動かしてるCI環境のせいだったりします。
現状が、
  • プロダクトビルド用のJenkinsがある
  • デグレードの検出に直前のバージョンとの返却値比較をしたい
  • ただし、デグレードのチェックは最近構築したJenkinsサーバでやりたい
    • ビルド用サーバは共有していてあまりいじりたくないので
  • ビルドジョブはそのままビルド用サーバ上で動かしたい
  • チェックが失敗したら、プロダクトのビルドも失敗させたい
  • 成否によらず、プロダクトのビルド結果からチェックのビルドを参照したい
という感じなので、複数サーバの連携が必要だと考えました。

今回の要件としては、
  • python2.7で動く
  • pip installはNG
  • ビルドしたジョブが終わるのを待機できる
  • ビルドしたジョブのURLを出力する
  • できれば1ファイル
というところでしょうか。

JenkinsのREST APIを色々試していると、
  1. 対象ジョブのビルド開始
    1. ${JOBNAME}/buildWithParameters を叩く
  2. ビルドキューの状態確認
    1. ${JOBNAME}/buildWithParameters のレスポンスについてくるLocationヘッダに対象ビルドに関するキューのステータス取得用パスが入ってる(実際には、api/jsonなどを末尾に付与する必要はありますが)
    2. JSONの場合、ステータスとして取得したオブジェクトにexecutableフィールドが存在しない間はキューで待ち状態になっているようなので、返ってくるまで定期的にポーリング
    3. executableフィールドが返ってきたら、中のurlを使ってビルド自体の進捗をチェック
  3. ビルド自体の状態確認
    1. キューの状態確認で取れたexecutableフィールドのurlにアクセスすると、ビルドの状態が取れる
    2. ビルドが完了していない間は、取得したオブジェクトのresultフィールドがnullになっているので、これまた定期的にポーリング
    3. null以外になったら、入っている文字列が最終的なビルド結果
という感じで必要な情報は全部取れそうなので、この流れで実装すれば大丈夫そうです。

そして実装したソースコード全体は以下の通りです。
#!/usr/bin/env python
# -*- coding: utf-8-unix -*-
"""build remote jenkins job and wait it until finish to build.
Usage:
syncbuild_remote_jenkins.py --job JOB [--host HOST] [--port PORT] [--base-path PATH]
[--params PARAMS] [--interval INTERVAL]
syncbuild_remote_jenkins.py -h|--help
Options:
--job JOB target jenkins job name.
--host HOST jenkins hostname [default: localhost].
--port PORT jenkins port number [default: 8080].
--base-path PATH jenkins base path [default: /job].
--params PARAMS parameter used by remote job in JSON format.
--interval INTERVAL interval second to poll remote job state [default: 5].
-h --help show this help message and exit
-v --verbose set logging level to debug.
"""
"""
:auther: kyama http://sukimaprogrammer.blogspot.jp/
:since: 0.0.1
:version: 0.0.4
This source is distributed under the MIT License.
Copyright (c) 2015 lazykyama http://sukimaprogrammer.blogspot.jp/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
__version__='0.0.4'
__author__='lazykyama'
__license__='MIT License'
__copyright__='Copyright (c) 2015 lazykyama'
import argparse
import json
import logging
import sys
import time
import urllib2
import urllib
DEFAULT_QUEUE_POLLING_INTERVAL_SEC = 30
def __validate_args(args):
def validate_error(param, validator, err_msg, converter=lambda p: p):
if not validator(param):
sys.stderr.write('[ERROR] {}.\n'.format(err_msg))
raise RuntimeError('fail to validate parameter.')
return converter(param)
validators = {
'host': lambda p: validate_error(p, lambda p: len(p) > 0,
'invalid hostname: {}'.format(p)),
'port': lambda p: validate_error(p, lambda p: 0 < p < 65536,
'invalid portnum: {}'.format(p)),
'params': lambda p: validate_error(p, lambda p: p is not None and len(p) > 0,
'invalid parmaeter: {}'.format(p),
converter = lambda p: json.loads(p)),
'interval': lambda p: validate_error(p, lambda p: p > 0,
'invalid interval second: {}'.format(p))
}
args.host = validators['host'](args.host)
args.port = validators['port'](args.port)
args.params = validators['params'](args.params)
args.interval = validators['interval'](args.interval)
return args
def __parse_args():
DEFAULT_HOST = 'localhost'
DEFAULT_PORT = 8080
DEFAULT_BASE_PATH = '/job'
STATEPOLLING_INTERVAL_SECOND = 5
parser = argparse.ArgumentParser(
description='kick and wait remote jenkins job until finishing it.')
parser.add_argument('--host', type=str, default=DEFAULT_HOST,
help='jenkins hostname [default: {}].'.format(DEFAULT_HOST))
parser.add_argument('--port', type=int, default=DEFAULT_PORT,
help='jenkins port number [default: {}].'.format(DEFAULT_PORT))
parser.add_argument('--base-path', type=str, default=DEFAULT_BASE_PATH,
help='jenkins base path [default: {}].'.format(DEFAULT_BASE_PATH))
parser.add_argument('--job', type=str, required=True,
help='target jenkins job name.')
parser.add_argument('--params', type=str, default='{}',
help='parameter used by remote job in JSON format.')
parser.add_argument('--interval', type=int, default=STATEPOLLING_INTERVAL_SECOND,
help='interval second to poll remote job state [default: {}].'.format(
STATEPOLLING_INTERVAL_SECOND))
group = parser.add_mutually_exclusive_group()
group.add_argument("-v", "--verbose", dest="verbose",
default=False, action="store_true",
help="set logging level to debug.")
args = __validate_args(parser.parse_args())
if args.verbose:
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s')
else:
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
logging.debug(args)
return args
def __fetch_remote_state(url):
logging.debug('send request to get remote status: {}.'.format(url))
response = urllib2.urlopen(url)
if response.code != 200:
raise RuntimeError(
'unexpected status: {}'.format(response.code))
return json.load(response)
def __build_remote_job(args):
kick_remote_job_path = 'http://{}:{}{}/{}/buildWithParameters'.format(
args.host, args.port, args.base_path, args.job)
params = urllib.urlencode(args.params)
kick_remote_job_url = '{}?{}'.format(kick_remote_job_path, params)
logging.debug('send request to create job: {}.'.format(kick_remote_job_url))
response = urllib2.urlopen(kick_remote_job_url)
if response.code != 201:
raise RuntimeError(
'unexpected status in building job: {}'.format(response.code))
location = response.info().getheader('Location')
if location is None:
raise RuntimeError(
'unexpected response header without Location.')
queue_state_url = '{}api/json'.format(location)
build_url = None
while build_url is None:
queue_state = __fetch_remote_state(queue_state_url)
executable_info = 'executable' in queue_state and queue_state['executable']
if not executable_info:
logging.debug('wait to release from queue.')
time.sleep(DEFAULT_QUEUE_POLLING_INTERVAL_SEC)
continue
build_url = executable_info['url']
return build_url
def __wait_to_finish_remote_job(args, build_url):
job_state_url = '{}api/json'.format(build_url)
state = None
while state is None:
response = __fetch_remote_state(job_state_url)
result = 'result' in response and response['result']
if not result:
logging.debug('wait to finish building job.')
time.sleep(args.interval)
continue
state = result
return state
def main():
args = __parse_args()
try:
print('build remote job[{}].'.format(args.job))
build_url = __build_remote_job(args)
state = __wait_to_finish_remote_job(args, build_url)
except RuntimeError as e:
logging.error('fail to build remote job: {}'.format(e))
return 1
print('buliding remote job[{}] is {}.'.format(args.job, state))
print('build result: {}'.format(build_url))
if state != 'SUCCESS':
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
会社で動かすものはいろいろな事情で制約がつきやすいと思いますので、そういう時に活用していただけると良いかと思います。

0 件のコメント:

コメントを投稿