diff --git a/doc/source/examples.rst b/doc/source/examples.rst index cded8b7..536b2c9 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -235,3 +235,34 @@ where *prom_name* is the name of the promotion that will get added to the job. print server.get_promotion_config('prom_name', 'prom_job') server.delete_promotion('prom_name', 'prom_job') + + +Example 10: Waiting for Jenkins to be ready +------------------------------------------- + +It is possible to ask the API to wait for Jenkins to be ready with a given +timeout. This can be used to aid launching of Jenkins and then waiting for the +REST API to be responsive before continuing with subsequent configuration. + +:: + # timeout here is the socket connection timeout, for each connection + # attempt it will wait at most 5 seconds before assuming there is + # nothing listening. Useful where firewalls may black hole connections. + server = jenkins.Jenkins('http://localhost:8080', timeout=5) + + # wait for at least 30 seconds for Jenkins to be ready + if server.wait_for_normal_op(30): + # actions once running + ... + else: + print("Jenkins failed to be ready in sufficient time") + exit 2 + +Note that the timeout arg to `jenkins.Jenkins()` is the socket connection +timeout. If you set this to be more than the timeout value passed to +`wait_for_normal_op()`, then in cases where the underlying connection is not +rejected (firewall black-hole, or slow connection) then `wait_for_normal_op()` +may wait at least the connection timeout, or a multiple of it where multiple +connection attempts are made. A connection timeout of 5 seconds and a wait +timeout of 8 will result in potentially waiting 10 seconds if both connections +attempts do not get responses. diff --git a/jenkins/__init__.py b/jenkins/__init__.py index 3680aeb..5977039 100755 --- a/jenkins/__init__.py +++ b/jenkins/__init__.py @@ -252,6 +252,7 @@ def auth_headers(username, password): class Jenkins(object): + _timeout_warning_issued = False def __init__(self, url, username=None, password=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): @@ -1642,3 +1643,61 @@ class Jenkins(object): info = self.get_info() if not info['quietingDown']: raise JenkinsException('quiet down failed') + + def wait_for_normal_op(self, timeout): + '''Wait for jenkins to enter normal operation mode. + + :param timeout: number of seconds to wait, ``int`` + Note this is not the same as the connection timeout set via + __init__ as that controls the socket timeout. Instead this is + how long to wait until the status returned. + :returns: ``True`` if Jenkins became ready in time, ``False`` + otherwise. + + Setting timeout to be less than the configured connection timeout + may result in this waiting for at least the connection timeout + length of time before returning. It is recommended that the timeout + here should be at least as long as any set connection timeout. + ''' + if timeout < 0: + raise ValueError("Timeout must be >= 0 not %d" % timeout) + + if (not self._timeout_warning_issued and + self.timeout != socket._GLOBAL_DEFAULT_TIMEOUT and + timeout < self.timeout): + warnings.warn("Requested timeout to wait for jenkins to resume " + "normal operations is less than configured " + "connection timeout. Unexpected behaviour may " + "occur.") + self._timeout_warning_issued = True + + start_time = time.time() + + def is_ready(): + # only call get_version until it returns without exception + while True: + if self.get_version(): + while True: + # json API will only return valid info once Jenkins + # is ready, so just check any known field exists + # when not in normal mode, most requests will + # be ignored or fail + yield 'mode' in self.get_info() + else: + yield False + + while True: + try: + if next(is_ready()): + return True + except (KeyError, JenkinsException): + # key missing from JSON, empty response or errors in + # get_info due to incomplete HTTP responses + pass + # check time passed as the communication will also + # take time + if time.time() > start_time + timeout: + break + time.sleep(1) + + return False diff --git a/tests/test_jenkins.py b/tests/test_jenkins.py index 154f99b..1f972f2 100644 --- a/tests/test_jenkins.py +++ b/tests/test_jenkins.py @@ -222,3 +222,27 @@ class JenkinsOpenTest(JenkinsTestBase): jenkins_mock.call_args[0][0].get_full_url(), self.make_url('job/TestJob')) self._check_requests(jenkins_mock.call_args_list) + + @patch.object(jenkins.Jenkins, 'jenkins_open', + return_value=json.dumps({'mode': 'NORMAL'})) + @patch.object(jenkins.Jenkins, 'get_version', + return_value="Version42") + def test_wait_for_normal_op(self, version_mock, jenkins_mock): + j = jenkins.Jenkins('http://example.com', 'test', 'test') + self.assertTrue(j.wait_for_normal_op(0)) + + @patch.object(jenkins.Jenkins, 'jenkins_open', + side_effect=jenkins.EmptyResponseException()) + @patch.object(jenkins.Jenkins, 'get_version', + side_effect=jenkins.EmptyResponseException()) + def test_wait_for_normal_op__empty_response(self, version_mock, jenkins_mock): + j = jenkins.Jenkins('http://example.com', 'test', 'test') + self.assertFalse(j.wait_for_normal_op(0)) + + def test_wait_for_normal_op__negative_timeout(self): + j = jenkins.Jenkins('http://example.com', 'test', 'test') + with self.assertRaises(ValueError) as context_manager: + j.wait_for_normal_op(-1) + self.assertEqual( + str(context_manager.exception), + "Timeout must be >= 0 not -1")