From 2f5304e8e1a0b6d8788879958f1d11b801f51b12 Mon Sep 17 00:00:00 2001 From: Christine Lytwynec Date: Tue, 7 Apr 2015 16:09:55 -0400 Subject: [PATCH] reorganize and add build timeout script --- .gitignore | 1 + .travis.yml | 2 +- duplicate_ghprb_jobs/requirements.txt | 1 - duplicate_ghprb_jobs/tests/test_deduper.py | 223 ------------------ {duplicate_ghprb_jobs => jenkins}/__init__.py | 0 {duplicate_ghprb_jobs => jenkins}/deduper.py | 117 ++------- jenkins/helpers.py | 22 ++ jenkins/job.py | 97 ++++++++ jenkins/requirements.txt | 1 + jenkins/tests/__init__.py | 0 jenkins/tests/helpers.py | 78 ++++++ jenkins/tests/test_deduper.py | 91 +++++++ jenkins/tests/test_helpers.py | 25 ++ jenkins/tests/test_job.py | 50 ++++ jenkins/tests/test_timeout.py | 105 +++++++++ jenkins/timeout.py | 137 +++++++++++ test-requirements.txt | 2 +- 17 files changed, 623 insertions(+), 329 deletions(-) delete mode 100644 duplicate_ghprb_jobs/requirements.txt delete mode 100644 duplicate_ghprb_jobs/tests/test_deduper.py rename {duplicate_ghprb_jobs => jenkins}/__init__.py (100%) rename {duplicate_ghprb_jobs => jenkins}/deduper.py (62%) create mode 100644 jenkins/helpers.py create mode 100644 jenkins/job.py create mode 100644 jenkins/requirements.txt create mode 100644 jenkins/tests/__init__.py create mode 100644 jenkins/tests/helpers.py create mode 100644 jenkins/tests/test_deduper.py create mode 100644 jenkins/tests/test_helpers.py create mode 100644 jenkins/tests/test_job.py create mode 100644 jenkins/tests/test_timeout.py create mode 100644 jenkins/timeout.py diff --git a/.gitignore b/.gitignore index 6c3b031f..cba3e3ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *~ *.swp *.orig +*.pyc /nbproject .idea/ .redcar/ diff --git a/.travis.yml b/.travis.yml index f84ec162..5a7e6a82 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,4 +9,4 @@ install: - pip install -r test-requirements.txt script: - pep8 . - - nosetests duplicate_ghprb_jobs -v --with-coverage --cover-package=duplicate_ghprb_jobs + - nosetests jenkins -v --with-coverage --cover-package=jenkins diff --git a/duplicate_ghprb_jobs/requirements.txt b/duplicate_ghprb_jobs/requirements.txt deleted file mode 100644 index 96d64abb..00000000 --- a/duplicate_ghprb_jobs/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests==2.5.3 \ No newline at end of file diff --git a/duplicate_ghprb_jobs/tests/test_deduper.py b/duplicate_ghprb_jobs/tests/test_deduper.py deleted file mode 100644 index 50919f93..00000000 --- a/duplicate_ghprb_jobs/tests/test_deduper.py +++ /dev/null @@ -1,223 +0,0 @@ -import json -from time import time -from mock import patch, Mock -from requests import Response -from requests.exceptions import HTTPError -from unittest import TestCase - -from duplicate_ghprb_jobs.deduper import ( - GhprbOutdatedBuildAborter, - append_url, - deduper_main, -) - - -def sample_data(running_builds, not_running_builds): - """ - Args: - running_builds: (list of str) A list of PR numbers that have - running builds. For example, ['1', '1', '2'] indicates - that there are currently 2 builds running for PR #1 and - 1 build running for PR #2. The last instance of '1' in - the list will correlate to the currently relevant build. - The build number will be the array index of the item. - not_running_builds: (list of str) A list of PR numbers that - have previously run builds. The build number will be the - array index of the item plus the length of running_builds. - Returns: - Python dict of build data. This is in the format expected to - be returned by the jenkins api. - """ - builds = [] - first_time = int(time()) - for i in range(0, len(running_builds)): - builds.append( - '{"actions" : [{"parameters" :[{"name": "ghprbPullId",' - '"value" : "' + running_builds[i] + '"}]},{},{}], ' - '"building": true, "number": ' + str(i) + - ', "timestamp" : ' + str(first_time + i) + '}' - ) - - for i in range(0, len(not_running_builds)): - builds.append( - '{"actions" : [{"parameters" :[{"name": "ghprbPullId",' - '"value" : "' + not_running_builds[i] + '"}]},{},{}], ' - '"building": false, "number": ' + str(i + len(running_builds)) - + ', "timestamp" : ' + str(first_time - i) + '}' - ) - - build_data = ''.join([ - '{"builds": [', - ','.join(builds), - ']}', - ]) - - return json.loads(build_data) - - -def mock_response(status_code, data=None): - mock_response = Response() - mock_response.status_code = status_code - mock_response.json = Mock(return_value=data) - return mock_response - - -class DeduperTestCase(TestCase): - - """ - TestCase class for testing deduper.py. - """ - - def setUp(self): - self.job_url = 'http://localhost:8080/fakejenkins' - self.user = 'ausername' - self.api_key = 'apikey' - self.deduper = GhprbOutdatedBuildAborter( - self.job_url, self.user, self.api_key) - - def test_append_url(self): - expected = 'http://my_base_url.com/the_extra_part' - inputs = [ - ('http://my_base_url.com', 'the_extra_part'), - ('http://my_base_url.com', '/the_extra_part'), - ('http://my_base_url.com/', 'the_extra_part'), - ('http://my_base_url.com/', '/the_extra_part'), - ] - - for i in inputs: - returned = append_url(*i) - self.assertEqual(expected, returned, - msg="{e} != {r}\nInputs: {i}".format( - e=expected, - r=returned, - i=str(i) - ) - ) - - def test_get_running_builds_3_building(self): - data = sample_data(['1', '2', '3'], ['4']) - builds = self.deduper.get_running_builds(data) - self.assertEqual(len(builds), 3) - - def test_get_running_builds_1_building(self): - data = sample_data(['4'], ['1', '2', '3']) - builds = self.deduper.get_running_builds(data) - self.assertEqual(len(builds), 1) - - def test_get_running_builds_none_building(self): - data = sample_data([], ['1', '2', '3', '4']) - builds = self.deduper.get_running_builds(data) - self.assertEqual(len(builds), 0) - - def test_description(self): - expected = ("[PR #2] Build automatically aborted because " - "there is a newer build for the same PR. See build #1.") - returned = self.deduper._aborted_description(1, 2) - self.assertEqual(expected, returned) - - def test_get_json_ok(self): - data = sample_data(['1'], []) - with patch('requests.get', return_value=mock_response(200, data)): - response = self.deduper.get_json() - self.assertEqual(data, response) - - def test_get_json_bad_response(self): - with patch('requests.get', return_value=mock_response(400)): - with self.assertRaises(HTTPError): - response = self.deduper.get_json() - - def test_stop_build_ok(self): - with patch('requests.post', return_value=mock_response(200, '')): - response = self.deduper.stop_build('20') - self.assertTrue(response) - - def test_stop_build_bad_response(self): - with patch('requests.post', return_value=mock_response(400, '')): - with self.assertRaises(HTTPError): - response = self.deduper.stop_build('20') - - def test_update_desc_ok(self): - with patch('requests.post', return_value=mock_response(200, '')): - response = self.deduper.update_build_desc('20', 'new description') - self.assertTrue(response) - - def test_update_desc_bad_response(self): - with patch('requests.post', return_value=mock_response(400, '')): - with self.assertRaises(HTTPError): - response = self.deduper.update_build_desc( - '20', 'new description') - - @patch( - 'duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.stop_build', - return_value=True) - @patch( - ('duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.' - 'update_build_desc'), - return_value=True) - @patch( - ('duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.' - '_aborted_description'), - return_value='new description') - def test_stop_duplicates_with_duplicates(self, mock_desc, - update_desc, - stop_build): - sample_buid_data = sample_data(['1', '2', '2', '3'], []) - build_data = self.deduper.get_running_builds(sample_buid_data) - response = self.deduper.stop_duplicates(build_data) - stop_build.assert_called_once_with(1) - update_desc.assert_called_once_with(1, mock_desc()) - - @patch( - 'duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.stop_build', - side_effect=HTTPError()) - @patch( - ('duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.' - 'update_build_desc'), - return_value=True) - def test_stop_duplicates_failed_to_stop(self, update_desc, stop_build): - sample_buid_data = sample_data(['1', '2', '2', '3'], []) - build_data = self.deduper.get_running_builds(sample_buid_data) - response = self.deduper.stop_duplicates(build_data) - stop_build.assert_called_once_with(1) - self.assertFalse(update_desc.called) - - @patch( - 'duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.stop_build', - return_value=True) - @patch( - ('duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.' - 'update_build_desc'), - return_value=True) - def test_stop_duplicates_no_duplicates(self, update_desc, stop_build): - sample_buid_data = sample_data(['1', '2', '3'], ['2']) - build_data = self.deduper.get_running_builds(sample_buid_data) - response = self.deduper.stop_duplicates(build_data) - self.assertFalse(stop_build.called) - self.assertFalse(update_desc.called) - - @patch( - 'duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.get_json', - return_value=sample_data(['1', '2', '2', '3'], ['4', '5', '5'])) - @patch( - 'duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.stop_build', - return_value=True) - @patch( - ('duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.' - 'update_build_desc'), - return_value=True) - @patch( - ('duplicate_ghprb_jobs.deduper.GhprbOutdatedBuildAborter.' - '_aborted_description'), - return_value='new description') - def test_main(self, mock_desc, update_desc, stop_build, get_json): - args = [ - '-t', self.api_key, - '-u', self.user, - '-j', self.job_url, - '--log-level', 'INFO', - ] - - deduper_main(args) - get_json.assert_called_once_with() - stop_build.assert_called_once_with(1) - update_desc.assert_called_once_with(1, mock_desc()) diff --git a/duplicate_ghprb_jobs/__init__.py b/jenkins/__init__.py similarity index 100% rename from duplicate_ghprb_jobs/__init__.py rename to jenkins/__init__.py diff --git a/duplicate_ghprb_jobs/deduper.py b/jenkins/deduper.py similarity index 62% rename from duplicate_ghprb_jobs/deduper.py rename to jenkins/deduper.py index 0ef7e479..e0be4bb1 100644 --- a/duplicate_ghprb_jobs/deduper.py +++ b/jenkins/deduper.py @@ -7,33 +7,16 @@ from collections import defaultdict from operator import itemgetter import argparse -import json import logging +import json import re -import requests import sys -logging.basicConfig(format='[%(levelname)s] %(message)s') -logger = logging.getLogger(__name__) +from job import JenkinsJob -def append_url(base, addition): - """ - Add something to a url, ensuring that there are the - right amount of `/`. - - :Args: - base: The original url. - addition: the thing to add to the end of the url - - :Returns: The combined url as a string of the form - `base/addition` - """ - if not base.endswith('/'): - base += '/' - if addition.startswith('/'): - addition = addition[1:] - return base + addition +logging.basicConfig(format='[%(levelname)s] %(message)s') +logger = logging.getLogger(__name__) class GhprbOutdatedBuildAborter: @@ -42,13 +25,11 @@ class GhprbOutdatedBuildAborter: jenkins builds that were started using the GHPRB plugin. :Args: - job_url: URL of jenkins job that uses the GHPRB plugin - username: jenkins userme - token: jeknins api token + job: An instance of jenkins_api.job.JenkinsJob """ - def __init__(self, job_url, username, token): - self.job_url = job_url - self.auth = (username, token) + + def __init__(self, job): + self.job = job @staticmethod def _aborted_description(current_build_id, pr): @@ -72,37 +53,10 @@ def abort_duplicate_builds(self): description of aborted jobs to indicate why they where stopped. """ - data = self.get_json() + data = self.job.get_json() builds = self.get_running_builds(data) self.stop_duplicates(builds) - def get_json(self): - """ - Get build data for a given job_url. - - :Returns: - A python dict from the jeknins api response including: - * builds: a list of dicts, each containing: - ** building: Boolean of whether it is actively building - ** timestamp: the time the build started - ** number: the build id number - ** actions: a list of 'actions', from which the only - item used in this script is 'parameters' which can - be used to find the PR number. - """ - api_url = append_url(self.job_url, '/api/json') - - response = requests.get( - api_url, - params={ - "tree": ("builds[building,timestamp," - "number,actions[parameters[*]]]"), - } - ) - - response.raise_for_status() - return response.json() - @staticmethod def get_running_builds(data): """ @@ -165,8 +119,8 @@ def stop_duplicates(self, build_data): for b in old_build_ids: try: - self.stop_build(b) - self.update_build_desc(b, desc) + self.job.stop_build(b) + self.job.update_build_desc(b, desc) except Exception as e: logger.error(e.message) @@ -179,49 +133,6 @@ def stop_duplicates(self, build_data): else: logger.info("No extra running builds found.") - def update_build_desc(self, build_id, description): - """ - Updates build description. - - :Args: - build_id: id number of build to update - description: the new description - """ - build_url = append_url(self.job_url, str(build_id)) - url = append_url(build_url, "/submitDescription") - - response = requests.post( - url, - auth=self.auth, - params={ - 'description': description, - }, - ) - - logger.info("Updating description for build #{}. Response: {}".format( - build_id, response.status_code)) - - response.raise_for_status() - return response.ok - - def stop_build(self, build_id): - """ - Stops a build. - - :Args: - build_id: id number of build to abort - """ - build_url = append_url(self.job_url, str(build_id)) - url = append_url(build_url, "/stop") - - response = requests.post(url, auth=self.auth) - - logger.info("Aborting build #{}. Response: {}".format( - build_id, response.status_code)) - - response.raise_for_status() - return response.ok - def deduper_main(raw_args): # Get args @@ -240,11 +151,11 @@ def deduper_main(raw_args): args = parser.parse_args(raw_args) # Set logging level - logger.setLevel(args.log_level.upper()) + logging.getLogger().setLevel(args.log_level.upper()) # Abort extra jobs - deduper = GhprbOutdatedBuildAborter( - args.job_url, args.username, args.token) + job = JenkinsJob(args.job_url, args.username, args.token) + deduper = GhprbOutdatedBuildAborter(job) deduper.abort_duplicate_builds() diff --git a/jenkins/helpers.py b/jenkins/helpers.py new file mode 100644 index 00000000..5318af10 --- /dev/null +++ b/jenkins/helpers.py @@ -0,0 +1,22 @@ +""" +Helpers for jenkins api +""" + + +def append_url(base, addition): + """ + Add something to a url, ensuring that there are the + right amount of `/`. + + :Args: + base: The original url. + addition: the thing to add to the end of the url + + :Returns: The combined url as a string of the form + `base/addition` + """ + if not base.endswith('/'): + base += '/' + if addition.startswith('/'): + addition = addition[1:] + return base + addition diff --git a/jenkins/job.py b/jenkins/job.py new file mode 100644 index 00000000..9036a129 --- /dev/null +++ b/jenkins/job.py @@ -0,0 +1,97 @@ +""" +A class to interact with a jenkins job API +""" +import logging +import requests + +from helpers import append_url + + +logging.basicConfig(format='[%(levelname)s] %(message)s') +logger = logging.getLogger(__name__) +logging.getLogger('requests').setLevel('ERROR') + + +class JenkinsJob: + """ + A class for interacting with the jenkins job API + + :Args: + job_url: URL of jenkins job + username: jenkins username + token: jenkins api token + """ + + def __init__(self, job_url, username, token): + self.job_url = job_url + self.auth = (username, token) + + def get_json(self): + """ + Get build data for a given job_url. + + :Returns: + A python dict from the jenkins api response including: + * builds: a list of dicts, each containing: + ** building: Boolean of whether it is actively building + ** timestamp: the time the build started + ** number: the build id number + ** actions: a list of 'actions', from which the only + item used in this script is 'parameters' which can + be used to find the PR number. + """ + api_url = append_url(self.job_url, '/api/json') + + response = requests.get( + api_url, + params={ + "tree": ("builds[building,timestamp," + "number,actions[parameters[*]]]"), + } + ) + + response.raise_for_status() + return response.json() + + def update_build_desc(self, build_id, description): + """ + Updates build description. + + :Args: + build_id: id number of build to update + description: the new description + """ + build_url = append_url(self.job_url, str(build_id)) + url = append_url(build_url, "/submitDescription") + + response = requests.post( + url, + auth=self.auth, + params={ + 'description': description, + }, + ) + + logger.info("Updating description for build #{}. Response: {}".format( + build_id, response.status_code)) + + response.raise_for_status() + return response.ok + + def stop_build(self, build_id): + """ + Stops a build. + + :Args: + build_id: id number of build to abort + """ + build_url = append_url(self.job_url, str(build_id)) + url = append_url(build_url, "/stop") + + response = requests.post(url, auth=self.auth) + + logger.info("Aborting build #{}. Response: {}".format( + build_id, response.status_code)) + + response.raise_for_status() + return response.ok diff --git a/jenkins/requirements.txt b/jenkins/requirements.txt new file mode 100644 index 00000000..3b9ed6d3 --- /dev/null +++ b/jenkins/requirements.txt @@ -0,0 +1 @@ +requests==2.5.3 diff --git a/jenkins/tests/__init__.py b/jenkins/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jenkins/tests/helpers.py b/jenkins/tests/helpers.py new file mode 100644 index 00000000..52a0fa88 --- /dev/null +++ b/jenkins/tests/helpers.py @@ -0,0 +1,78 @@ +import json +import datetime +import functools +from mock import Mock +from requests import Response + + +def mock_response(status_code, data=None): + response = Response() + response.status_code = status_code + response.json = Mock(return_value=data) + return response + + +def mock_utcnow(func): + class MockDatetime(datetime.datetime): + @classmethod + def utcnow(cls): + return datetime.datetime.utcfromtimestamp(142009200.0) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + olddatetime = datetime.datetime + datetime.datetime = MockDatetime + ret = func(*args, **kwargs) + datetime.datetime = olddatetime + return ret + + return wrapper + + +def sample_data(running_builds, not_running_builds): + """ + Args: + running_builds: (list of str) A list of PR numbers that have + running builds. For example, ['1', '1', '2'] indicates + that there are currently 2 builds running for PR #1 and + 1 build running for PR #2. The last instance of '1' in + the list will correlate to the currently relevant build. + The build number will be the array index of the item. + not_running_builds: (list of str) A list of PR numbers that + have previously run builds. The build number will be the + array index of the item plus the length of running_builds. + Returns: + Python dict of build data. This is in the format expected to + be returned by the jenkins api. + """ + builds = [] + + def mktimestamp(minutes_ago): + first_time = 142009200 * 1000 + build_time = first_time - (minutes_ago * 60000) + return str(build_time) + + for i in range(0, len(running_builds)): + builds.append( + '{"actions" : [{"parameters" :[{"name": "ghprbPullId",' + '"value" : "' + running_builds[i] + '"}]},{},{}], ' + '"building": true, "number": ' + str(i) + + ', "timestamp" : ' + mktimestamp(i) + '}' + ) + + for i in range(0, len(not_running_builds)): + num = i + len(running_builds) + builds.append( + '{"actions" : [{"parameters" :[{"name": "ghprbPullId",' + '"value" : "' + not_running_builds[i] + '"}]},{},{}], ' + '"building": false, "number": ' + str(num) + + ', "timestamp" : ' + mktimestamp(num) + '}' + ) + + build_data = ''.join([ + '{"builds": [', + ','.join(builds), + ']}', + ]) + + return json.loads(build_data) diff --git a/jenkins/tests/test_deduper.py b/jenkins/tests/test_deduper.py new file mode 100644 index 00000000..c1fbaae7 --- /dev/null +++ b/jenkins/tests/test_deduper.py @@ -0,0 +1,91 @@ +from mock import patch +from requests.exceptions import HTTPError +from unittest import TestCase + +from jenkins.tests.helpers import mock_response, sample_data +from jenkins.deduper import GhprbOutdatedBuildAborter, deduper_main +from jenkins.job import JenkinsJob + + +class DeduperTestCase(TestCase): + """ + TestCase class for testing deduper.py. + """ + + def setUp(self): + self.job_url = 'http://localhost:8080/fakejenkins' + self.user = 'ausername' + self.api_key = 'apikey' + job = JenkinsJob(self.job_url, self.user, self.api_key) + self.deduper = GhprbOutdatedBuildAborter(job) + + def test_get_running_builds_3_building(self): + data = sample_data(['1', '2', '3'], ['4']) + builds = self.deduper.get_running_builds(data) + self.assertEqual(len(builds), 3) + + def test_get_running_builds_1_building(self): + data = sample_data(['4'], ['1', '2', '3']) + builds = self.deduper.get_running_builds(data) + self.assertEqual(len(builds), 1) + + def test_get_running_builds_none_building(self): + data = sample_data([], ['1', '2', '3', '4']) + builds = self.deduper.get_running_builds(data) + self.assertEqual(len(builds), 0) + + def test_description(self): + expected = ("[PR #2] Build automatically aborted because " + "there is a newer build for the same PR. See build #1.") + returned = self.deduper._aborted_description(1, 2) + self.assertEqual(expected, returned) + + @patch('jenkins.job.JenkinsJob.stop_build', return_value=True) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + @patch('jenkins.deduper.GhprbOutdatedBuildAborter._aborted_description', + return_value='new description') + def test_stop_duplicates_with_duplicates(self, mock_desc, + update_desc, + stop_build): + sample_buid_data = sample_data(['1', '2', '2', '3'], []) + build_data = self.deduper.get_running_builds(sample_buid_data) + self.deduper.stop_duplicates(build_data) + stop_build.assert_called_once_with(2) + update_desc.assert_called_once_with(2, mock_desc()) + + @patch('jenkins.job.JenkinsJob.stop_build', side_effect=HTTPError()) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + def test_stop_duplicates_failed_to_stop(self, update_desc, stop_build): + sample_buid_data = sample_data(['1', '2', '2', '3'], []) + build_data = self.deduper.get_running_builds(sample_buid_data) + self.deduper.stop_duplicates(build_data) + stop_build.assert_called_once_with(2) + self.assertFalse(update_desc.called) + + @patch('jenkins.job.JenkinsJob.stop_build', return_value=True) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + def test_stop_duplicates_no_duplicates(self, update_desc, stop_build): + sample_buid_data = sample_data(['1', '2', '3'], ['2']) + build_data = self.deduper.get_running_builds(sample_buid_data) + self.deduper.stop_duplicates(build_data) + self.assertFalse(stop_build.called) + self.assertFalse(update_desc.called) + + @patch('jenkins.job.JenkinsJob.get_json', + return_value=sample_data(['1', '2', '2', '3'], ['4', '5', '5'])) + @patch('jenkins.job.JenkinsJob.stop_build', return_value=True) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + @patch('jenkins.deduper.GhprbOutdatedBuildAborter._aborted_description', + return_value='new description') + def test_main(self, mock_desc, update_desc, stop_build, get_json): + args = [ + '-t', self.api_key, + '-u', self.user, + '-j', self.job_url, + '--log-level', 'INFO', + ] + + deduper_main(args) + get_json.assert_called_once_with() + stop_build.assert_called_once_with(2) + update_desc.assert_called_once_with(2, mock_desc()) diff --git a/jenkins/tests/test_helpers.py b/jenkins/tests/test_helpers.py new file mode 100644 index 00000000..50b31902 --- /dev/null +++ b/jenkins/tests/test_helpers.py @@ -0,0 +1,25 @@ +from unittest import TestCase + +from jenkins.helpers import append_url + + +class HelpersTestCase(TestCase): + + def test_append_url(self): + expected = 'http://my_base_url.com/the_extra_part' + inputs = [ + ('http://my_base_url.com', 'the_extra_part'), + ('http://my_base_url.com', '/the_extra_part'), + ('http://my_base_url.com/', 'the_extra_part'), + ('http://my_base_url.com/', '/the_extra_part'), + ] + + for i in inputs: + returned = append_url(*i) + self.assertEqual( + expected, + returned, + msg="{e} != {r}\nInputs: {i}".format( + e=expected, r=returned, i=str(i) + ) + ) diff --git a/jenkins/tests/test_job.py b/jenkins/tests/test_job.py new file mode 100644 index 00000000..70b4e23f --- /dev/null +++ b/jenkins/tests/test_job.py @@ -0,0 +1,50 @@ +from mock import patch +from requests.exceptions import HTTPError +from unittest import TestCase + +from jenkins.tests.helpers import mock_response, sample_data +from jenkins.job import JenkinsJob + + +class JenkinsJobTestCase(TestCase): + """ + TestCase class for testing deduper.py. + """ + + def setUp(self): + self.job_url = 'http://localhost:8080/fakejenkins' + self.user = 'ausername' + self.api_key = 'apikey' + self.job = JenkinsJob(self.job_url, self.user, self.api_key) + + def test_get_json_ok(self): + data = sample_data(['1'], []) + with patch('requests.get', return_value=mock_response(200, data)): + response = self.job.get_json() + self.assertEqual(data, response) + + def test_get_json_bad_response(self): + with patch('requests.get', return_value=mock_response(400)): + with self.assertRaises(HTTPError): + self.job.get_json() + + def test_stop_build_ok(self): + with patch('requests.post', return_value=mock_response(200, '')): + response = self.job.stop_build('20') + self.assertTrue(response) + + def test_stop_build_bad_response(self): + with patch('requests.post', return_value=mock_response(400, '')): + with self.assertRaises(HTTPError): + self.job.stop_build('20') + + def test_update_desc_ok(self): + with patch('requests.post', return_value=mock_response(200, '')): + response = self.job.update_build_desc('20', 'new description') + self.assertTrue(response) + + def test_update_desc_bad_response(self): + with patch('requests.post', return_value=mock_response(400, '')): + with self.assertRaises(HTTPError): + self.job.update_build_desc( + '20', 'new description') diff --git a/jenkins/tests/test_timeout.py b/jenkins/tests/test_timeout.py new file mode 100644 index 00000000..78fa0216 --- /dev/null +++ b/jenkins/tests/test_timeout.py @@ -0,0 +1,105 @@ +from mock import patch, call +from requests.exceptions import HTTPError +from unittest import TestCase + +from jenkins.tests.helpers import sample_data, mock_utcnow +from jenkins.timeout import BuildTimeout, timeout_main +from jenkins.job import JenkinsJob + + +class TimeoutTestCase(TestCase): + """ + TestCase class for testing timeout.py. + """ + + def setUp(self): + self.job_url = 'http://localhost:8080/fakejenkins' + self.user = 'ausername' + self.api_key = 'apikey' + job = JenkinsJob(self.job_url, self.user, self.api_key) + self.timer = BuildTimeout(job, 2) + + @mock_utcnow + def test_get_stuck_builds_3_building(self): + data = sample_data(['1', '2', '3'], ['4']) + builds = self.timer.get_stuck_builds(data) + self.assertEqual(len(builds), 1) + + @mock_utcnow + def test_get_stuck_builds_1_building(self): + data = sample_data(['4'], ['1', '2', '3']) + builds = self.timer.get_stuck_builds(data) + self.assertEqual(len(builds), 0) + + @mock_utcnow + def test_get_stuck_builds_none_building(self): + data = sample_data([], ['1', '2', '3', '4']) + builds = self.timer.get_stuck_builds(data) + self.assertEqual(len(builds), 0) + + @mock_utcnow + def test_description(self): + expected = ("Build #1 automatically aborted because it has " + "exceeded the timeout of 3 minutes.") + returned = self.timer._aborted_description(3, 1) + self.assertEqual(expected, returned) + + @mock_utcnow + @patch('jenkins.job.JenkinsJob.stop_build', return_value=True) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + @patch('jenkins.timeout.BuildTimeout._aborted_description', + return_value='new description') + def test_stop_stuck_builds_with_stuck(self, mock_desc, + update_desc, + stop_build): + sample_buid_data = sample_data(['0', '1', '2', '3'], []) + build_data = self.timer.get_stuck_builds(sample_buid_data) + self.timer.stop_stuck_builds(build_data) + + stop_build.assert_has_calls([call(3), call(2)], any_order=True) + + update_desc.assert_hass_calls( + [call(2, mock_desc()), call(3, mock_desc())], + any_order=True + ) + + @mock_utcnow + @patch('jenkins.job.JenkinsJob.stop_build', side_effect=HTTPError()) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + def test_stop_stuck_builds_failed_to_stop(self, update_desc, stop_build): + sample_buid_data = sample_data(['1', '2', '3'], []) + build_data = self.timer.get_stuck_builds(sample_buid_data) + self.timer.stop_stuck_builds(build_data) + stop_build.assert_called_once_with(2) + self.assertFalse(update_desc.called) + + @mock_utcnow + @patch('jenkins.job.JenkinsJob.stop_build', return_value=True) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + def test_stop_stuck_builds_none_stuck(self, update_desc, stop_build): + sample_buid_data = sample_data(['1', '2'], ['2']) + build_data = self.timer.get_stuck_builds(sample_buid_data) + self.timer.stop_stuck_builds(build_data) + self.assertFalse(stop_build.called) + self.assertFalse(update_desc.called) + + @mock_utcnow + @patch('jenkins.job.JenkinsJob.get_json', + return_value=sample_data(['1', '2', '2'], ['4', '5', '5'])) + @patch('jenkins.job.JenkinsJob.stop_build', return_value=True) + @patch('jenkins.job.JenkinsJob.update_build_desc', return_value=True) + @patch('jenkins.timeout.BuildTimeout._aborted_description', + return_value='new description') + def test_main(self, mock_desc, update_desc, stop_build, get_json): + args = [ + '-t', self.api_key, + '-u', self.user, + '-j', self.job_url, + '--log-level', 'INFO', + '--timeout', '2', + ] + + timeout_main(args) + get_json.assert_called_once_with() + stop_build.assert_called_once_with(2) + update_desc.assert_called_once_with(2, mock_desc()) diff --git a/jenkins/timeout.py b/jenkins/timeout.py new file mode 100644 index 00000000..ed18b949 --- /dev/null +++ b/jenkins/timeout.py @@ -0,0 +1,137 @@ +""" +This script is intended to be used to abort builds that are assumed to be +stuck because they have exceeded the expected max time. +""" +import argparse +import datetime +import logging +import sys + +from job import JenkinsJob + + +logging.basicConfig(format='[%(levelname)s] %(message)s') +logger = logging.getLogger(__name__) + + +class BuildTimeout: + """ + A class for programatically finding and aborting stuck builds. + + :Args: + job: An instance of jenkins_api.job.JenkinsJob + """ + + def __init__(self, job, timeout): + self.job = job + self.timeout = int(timeout) + + @staticmethod + def _aborted_description(timeout, build): + """ + :Args: + timeout: the timeout length in minutes + pr: the PR id + + :Returns: A description (string) + """ + return ("Build #{} automatically aborted because it has exceeded" + " the timeout of {} minutes.".format(build, timeout)) + + def abort_stuck_builds(self): + """ + Find running builds of the job at self.job_url. + If there are builds that have been running for longer than + the set timeout, abort them. It updates the build + description of aborted builds to indicate why they where + stopped. + """ + data = self.job.get_json() + builds = self.get_stuck_builds(data) + self.stop_stuck_builds(builds) + + def get_stuck_builds(self, data): + """ + Return build data for currently running builds. + + :Args: + data: the return value of self.get_json() + + :Returns: + build_data: a list of build numbers as strings + """ + long_running_builds = [] + now = datetime.datetime.utcnow() + + for build in data['builds']: + # Need to divide by 1000 to get time in seconds + start_time = datetime.datetime.utcfromtimestamp( + build['timestamp'] / 1000.0) + time_delta = now - start_time + min_since_start = time_delta.total_seconds() / 60.0 + + if build['building'] and min_since_start >= self.timeout: + long_running_builds.append(build['number']) + + return long_running_builds + + def stop_stuck_builds(self, build_nums): + """ + Finds PRs that are stuck and abort them. + + :Args: + build_data: the data returned by self.get_running_builds() + """ + + lines = [] + for b in build_nums: + lines.append("Build #{} aborted due to timeout.".format(b)) + desc = self._aborted_description(self.timeout, b) + + try: + self.job.stop_build(b) + self.job.update_build_desc(b, desc) + except Exception as e: + logger.error(e.message) + + if lines: + out = ("\n---------------------------------" + "\n** Stuck builds found. **" + "\n---------------------------------\n") + out += "\n".join(lines) + logger.info(out) + else: + logger.info("No stuck builds found.") + + +def timeout_main(raw_args): + # Get args + parser = argparse.ArgumentParser( + description="Programatically abort builds that have been running" + "longer than a specified time") + parser.add_argument('--token', '-t', dest='token', + help='jeknins api token', required=True) + parser.add_argument('--user', '-u', dest='username', + help='jenkins username', required=True) + parser.add_argument('--job', '-j', dest='job_url', + help='URL of jenkins job that uses the GHPRB plugin', + required=True) + parser.add_argument('--timeout', dest='timeout', + help='A time in minutes at which we should consider' + 'a build to be stuck', + required=True) + parser.add_argument('--log-level', dest='log_level', + default="INFO", help="set logging level") + args = parser.parse_args(raw_args) + + # Set logging level + logging.getLogger().setLevel(args.log_level.upper()) + + # Abort builds that exceed timeout + job = JenkinsJob(args.job_url, args.username, args.token) + timer = BuildTimeout(job, args.timeout) + timer.abort_stuck_builds() + + +if __name__ == '__main__': + timeout_main(sys.argv[1:]) diff --git a/test-requirements.txt b/test-requirements.txt index 7b89b029..ed219b4b 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,4 +1,4 @@ --r duplicate_ghprb_jobs/requirements.txt +-r jenkins/requirements.txt coverage==3.7.1 nose==1.3.4