タイトルの通り、別サーバにあるJenkins間でジョブを連携させるスクリプトを勉強がてら書いたので、ことのついでに晒してみます。
ソースはgist(syncbuild_remote_jenkins.py)にあります。
動作環境などは↓のような感じ。
ちなみに、intervalオプションでジョブの状態をポーリングする間隔を変えられるようにしています。(上記の例だと300秒)
時間のかかるジョブであれば長めに設定すると無駄な処理が走らなくて良いかと。
一方、できないことは以下の通りです。
確かに便利なプラグインやライブラリは存在していて、最初はRemote Trigger Pluginを使おうと考えていました。
が、こいつの出力があまり充実しておらず、特にビルドした別サーバのJenkinsジョブのURLが出てこないのがちょっとマズかったので自作することにしました。
(設定いじると出てくるのであれば乗り換えたいですが。)
自作するにしても、なるべく簡単に作りたかったので単にRemote access APIをシェルから叩くつもりでしたが、レスポンスのパースとかが面倒くさそうだった大変そうだったのでPythonで実装しています。
現状が、
今回の要件としては、
JenkinsのREST APIを色々試していると、
そして実装したソースコード全体は以下の通りです。
会社で動かすものはいろいろな事情で制約がつきやすいと思いますので、そういう時に活用していただけると良いかと思います。
ソースは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をシェルから叩くつもりでしたが、レスポンスのパースとかが
実装方針とか
そもそも複数サーバのJenkinsを連携させようと考えたきっかけが、会社で動かしてるCI環境のせいだったりします。現状が、
- プロダクトビルド用のJenkinsがある
- デグレードの検出に直前のバージョンとの返却値比較をしたい
- ただし、デグレードのチェックは最近構築したJenkinsサーバでやりたい
- ビルド用サーバは共有していてあまりいじりたくないので
- ビルドジョブはそのままビルド用サーバ上で動かしたい
- チェックが失敗したら、プロダクトのビルドも失敗させたい
- 成否によらず、プロダクトのビルド結果からチェックのビルドを参照したい
今回の要件としては、
- python2.7で動く
- pip installはNG
- ビルドしたジョブが終わるのを待機できる
- ビルドしたジョブのURLを出力する
- できれば1ファイル
JenkinsのREST APIを色々試していると、
- 対象ジョブのビルド開始
- ${JOBNAME}/buildWithParameters を叩く
- ビルドキューの状態確認
- ${JOBNAME}/buildWithParameters のレスポンスについてくるLocationヘッダに対象ビルドに関するキューのステータス取得用パスが入ってる(実際には、api/jsonなどを末尾に付与する必要はありますが)
- JSONの場合、ステータスとして取得したオブジェクトにexecutableフィールドが存在しない間はキューで待ち状態になっているようなので、返ってくるまで定期的にポーリング
- executableフィールドが返ってきたら、中のurlを使ってビルド自体の進捗をチェック
- ビルド自体の状態確認
- キューの状態確認で取れたexecutableフィールドのurlにアクセスすると、ビルドの状態が取れる
- ビルドが完了していない間は、取得したオブジェクトのresultフィールドがnullになっているので、これまた定期的にポーリング
- null以外になったら、入っている文字列が最終的なビルド結果
という感じで必要な情報は全部取れそうなので、この流れで実装すれば大丈夫そうです。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 件のコメント:
コメントを投稿