From 978340a69410cb4b1c6b75b0ec97044abd7f82fa Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Thu, 10 Sep 2020 19:56:59 +0200 Subject: [PATCH] Add build-info subcommand The build-info subcommand fetches detailed information on a build given its UUID. It is possible to also either list the build's artifacts, the Ansible inventory used for the job, or download the build's console output. Fix incorrect info fetching. Change-Id: I1707ab083e4964a8ac410a7421f64acaffe06023 --- doc/source/commands.rst | 9 +++ ...uild-info_subcommand-a65eeceacf0c1103.yaml | 5 ++ requirements.txt | 1 + tests/unit/test_api.py | 56 +++++++++++++++ tests/unit/test_cmd.py | 71 ++++++++++++++++++- zuulclient/api/__init__.py | 25 ++++++- zuulclient/cmd/__init__.py | 57 ++++++++++++++- zuulclient/utils/formatters.py | 7 ++ 8 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 releasenotes/notes/build-info_subcommand-a65eeceacf0c1103.yaml diff --git a/doc/source/commands.rst b/doc/source/commands.rst index fa1604f..1d32be2 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -64,6 +64,15 @@ Examples:: zuul-client --use-conf sfio builds --tenant mytenant --result NODE_FAILURE zuul-client --use-conf opendev builds --tenant zuul --project zuul/zuul-client --limit 10 +Build-info +^^^^^^^^^^ +.. program-output:: zuul-client build-info --help + +Examples:: + + zuul-client build-info --tenant mytenant --uuid aaaaa + zuul-client build-info --tenant mytenant --uuid aaaaa --show-job-output + Dequeue ^^^^^^^ diff --git a/releasenotes/notes/build-info_subcommand-a65eeceacf0c1103.yaml b/releasenotes/notes/build-info_subcommand-a65eeceacf0c1103.yaml new file mode 100644 index 0000000..22c680e --- /dev/null +++ b/releasenotes/notes/build-info_subcommand-a65eeceacf0c1103.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add the **build-info** subcommand, allowing a user to fetch the details of + a given build by its UUID. diff --git a/requirements.txt b/requirements.txt index 58805d8..827f4eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ requests setuptools urllib3!=1.25.4,!=1.25.5 # https://github.com/urllib3/urllib3/pull/1684 PrettyTable +pyyaml diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index febc633..6662998 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -400,3 +400,59 @@ GuS6/ewjS+arA1Iyeg/IxmECAwEAAQ== 'https://fake.zuul/api/key/project1.pub' ) self.assertEqual(pubkey, key) + + def test_build(self): + """Test build endpoint""" + client = ZuulRESTClient(url='https://fake.zuul/') + # test status checks + self._test_status_check( + client, 'get', client.build, 'tenant1', 'a1a1a1a1') + + fakejson = { + 'uuid': 'a1a1a1a1', + 'job_name': 'tox-py38', + 'result': 'SUCCESS', + 'held': False, + 'start_time': '2020-09-10T14:08:55', + 'end_time': '2020-09-10T14:13:35', + 'duration': 280.0, + 'voting': True, + 'log_url': 'https://log.storage/', + 'node_name': None, + 'error_detail': None, + 'final': True, + 'artifacts': [ + {'name': 'Download all logs', + 'url': 'https://log.storage/download-logs.sh', + 'metadata': { + 'command': 'xxx'} + }, + {'name': 'Zuul Manifest', + 'url': 'https://log.storage/zuul-manifest.json', + 'metadata': { + 'type': 'zuul_manifest' + } + }, + {'name': 'Unit Test Report', + 'url': 'https://log.storage/testr_results.html', + 'metadata': { + 'type': 'unit_test_report' + } + }], + 'provides': [], + 'project': 'project1', + 'branch': 'master', + 'pipeline': 'check', + 'change': 1234, + 'patchset': '1', + 'ref': 'refs/changes/34/1234/1', + 'newrev': None, + 'ref_url': 'https://gerrit/1234', + 'event_id': '6b28762adfce415ba47e440c365ae624', + 'buildset': {'uuid': 'b1b1b1'}} + req = FakeRequestResponse(200, fakejson) + client.session.get = MagicMock(return_value=req) + ahl = client.build(tenant='tenant1', uuid='a1a1a1a1') + client.session.get.assert_any_call( + 'https://fake.zuul/api/tenant/tenant1/build/a1a1a1a1') + self.assertEqual(fakejson, ahl) diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py index 331a03e..f5b319a 100644 --- a/tests/unit/test_cmd.py +++ b/tests/unit/test_cmd.py @@ -161,7 +161,7 @@ verify_ssl=True""" ZC._main(['--zuul-url', 'https://fake.zuul', '--auth-token', 'aiaiaiai', ] + args) session.get = MagicMock( - side_effect=mock_get(info={'tenant': 'scoped'}) + side_effect=mock_get(info={'info': {'tenant': 'scoped'}}) ) with self.assertRaisesRegex(Exception, 'scoped to tenant "scoped"'): @@ -198,7 +198,7 @@ verify_ssl=True""" self.assertEqual(0, exit_code) # test scoped session.get = MagicMock( - side_effect=mock_get(info={'tenant': 'scoped'}) + side_effect=mock_get(info={'info': {'tenant': 'scoped'}}) ) exit_code = ZC._main( ['--zuul-url', 'https://scoped.zuul', @@ -552,7 +552,7 @@ verify_ssl=True""" '--pipeline', 'gate', '--tenant', 'tenant1', '--change', '1234', '--job', 'job1', '--held']) - session.get.assert_called_with( + session.get.assert_any_call( 'https://fake.zuul/api/tenant/tenant1/builds', params={'pipeline': 'gate', 'change': '1234', @@ -562,3 +562,68 @@ verify_ssl=True""" 'limit': 50} ) self.assertEqual(0, exit_code) + + def test_build_info(self): + """Test build-info subcommand""" + ZC = ZuulClient() + with self.assertRaisesRegex(Exception, + '--show-artifacts, --show-job-output and ' + '--show-inventory are mutually exclusive'): + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + 'build-info', '--tenant', 'tenant1', + '--uuid', 'a1a1a1a1', + '--show-artifacts', '--show-job-output']) + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + fakejson = { + 'uuid': 'a1a1a1a1', + 'job_name': 'tox-py38', + 'result': 'SUCCESS', + 'held': False, + 'start_time': '2020-09-10T14:08:55', + 'end_time': '2020-09-10T14:13:35', + 'duration': 280.0, + 'voting': True, + 'log_url': 'https://log.storage/', + 'node_name': None, + 'error_detail': None, + 'final': True, + 'artifacts': [ + {'name': 'Download all logs', + 'url': 'https://log.storage/download-logs.sh', + 'metadata': { + 'command': 'xxx'} + }, + {'name': 'Zuul Manifest', + 'url': 'https://log.storage/zuul-manifest.json', + 'metadata': { + 'type': 'zuul_manifest' + } + }, + {'name': 'Unit Test Report', + 'url': 'https://log.storage/testr_results.html', + 'metadata': { + 'type': 'unit_test_report' + } + }], + 'provides': [], + 'project': 'project1', + 'branch': 'master', + 'pipeline': 'check', + 'change': 1234, + 'patchset': '1', + 'ref': 'refs/changes/34/1234/1', + 'newrev': None, + 'ref_url': 'https://gerrit/1234', + 'event_id': '6b28762adfce415ba47e440c365ae624', + 'buildset': {'uuid': 'b1b1b1'}} + session.get = MagicMock( + return_value=FakeRequestResponse(200, fakejson)) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + 'build-info', '--tenant', 'tenant1', + '--uuid', 'a1a1a1a1']) + session.get.assert_any_call( + 'https://fake.zuul/api/tenant/tenant1/build/a1a1a1a1') + self.assertEqual(0, exit_code) diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py index 057ba51..d48a5c8 100644 --- a/zuulclient/api/__init__.py +++ b/zuulclient/api/__init__.py @@ -15,6 +15,7 @@ import requests import urllib.parse +import yaml class ZuulRESTException(Exception): @@ -63,7 +64,7 @@ class ZuulRESTClient(object): 'info') req = self.session.get(url) self._check_request_status(req) - self.info_ = req.json() + self.info_ = req.json().get('info', {}) return self.info_ def _check_request_status(self, req): @@ -270,3 +271,25 @@ class ZuulRESTClient(object): req = self.session.get(url, params=kwargs) self._check_request_status(req) return req.json() + + def build(self, tenant, uuid): + if self.info.get("tenant"): + self._check_scope(tenant) + suffix = "build/%s" % uuid + else: + suffix = "tenant/%s/build/%s" % (tenant, uuid) + url = urllib.parse.urljoin(self.base_url, suffix) + req = self.session.get(url) + self._check_request_status(req) + build_info = req.json() + build_info['job_output_url'] = urllib.parse.urljoin( + build_info['log_url'], 'job-output.txt') + inventory_url = urllib.parse.urljoin( + build_info['log_url'], 'zuul-info/inventory.yaml') + try: + raw_inventory = self.session.get(inventory_url) + build_info['inventory'] = yaml.load(raw_inventory.text, + Loader=yaml.SafeLoader) + except Exception as e: + build_info['inventory'] = {'error': str(e)} + return build_info diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index e069c29..fb07990 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -95,6 +95,7 @@ class ZuulClient(): self.add_promote_subparser(subparsers) self.add_encrypt_subparser(subparsers) self.add_builds_list_subparser(subparsers) + self.add_build_info_subparser(subparsers) return subparsers @@ -634,11 +635,63 @@ class ZuulClient(): os.unlink(pubkey_file.name) return return_code + def add_build_info_subparser(self, subparsers): + cmd_build_info = subparsers.add_parser( + 'build-info', help='Get info on a specific build') + cmd_build_info.add_argument( + '--tenant', help='tenant name', required=False, default='') + cmd_build_info.add_argument( + '--uuid', help='build UUID', required=True) + cmd_build_info.add_argument( + '--show-job-output', default=False, action='store_true', + help='Only download the job\'s output to the console') + cmd_build_info.add_argument( + '--show-artifacts', default=False, action='store_true', + help='Display only artifacts information for the build') + cmd_build_info.add_argument( + '--show-inventory', default=False, action='store_true', + help='Display only ansible inventory information for the build') + cmd_build_info.set_defaults(func=self.build_info) + self.cmd_build_info = cmd_build_info + + def build_info(self): + if sum(map(lambda x: x and 1 or 0, + [self.args.show_artifacts, + self.args.show_job_output, + self.args.show_inventory]) + ) > 1: + raise Exception( + '--show-artifacts, --show-job-output and ' + '--show-inventory are mutually exclusive' + ) + client = self.get_client() + self._check_tenant_scope(client) + build = client.build(self.tenant(), self.args.uuid) + if not build: + print('Build not found') + return False + if self.args.show_job_output: + output = client.session.get(build['job_output_url']) + client._check_request_status(output) + formatted_result = output.text + elif self.args.show_artifacts: + formatted_result = self.formatter('Artifacts')( + build.get('artifacts', []) + ) + elif self.args.show_inventory: + formatted_result = self.formatter('Inventory')( + build.get('inventory', {}) + ) + else: + formatted_result = self.formatter('Build')(build) + print(formatted_result) + return True + def add_builds_list_subparser(self, subparsers): cmd_builds = subparsers.add_parser( 'builds', help='List builds matching search criteria') cmd_builds.add_argument( - '--tenant', help='tenant name', required=True) + '--tenant', help='tenant name', required=False, default='') cmd_builds.add_argument( '--project', help='project name') cmd_builds.add_argument( @@ -678,6 +731,7 @@ class ZuulClient(): '--skip', help='how many results to skip', default=0, type=int) cmd_builds.set_defaults(func=self.builds) + self.cmd_builds = cmd_builds def builds(self): if self.args.voting and self.args.non_voting: @@ -713,6 +767,7 @@ class ZuulClient(): if self.args.held: filters['held'] = True client = self.get_client() + self._check_tenant_scope(client) request = client.builds(tenant=self.tenant(), **filters) formatted_result = self.formatter('Builds')(request) diff --git a/zuulclient/utils/formatters.py b/zuulclient/utils/formatters.py index 7d5ac9f..ccc9104 100644 --- a/zuulclient/utils/formatters.py +++ b/zuulclient/utils/formatters.py @@ -18,6 +18,7 @@ from dateutil.parser import isoparse import prettytable import json +import yaml class BaseFormatter: @@ -47,6 +48,9 @@ class BaseFormatter: def formatArtifacts(self, data): raise NotImplementedError + def formatInventory(self, data): + raise NotImplementedError + def formatBuild(self, data): raise NotImplementedError @@ -178,6 +182,9 @@ class PrettyTableFormatter(BaseFormatter): artifact.get('url', 'N/A')]) return str(table) + def formatInventory(self, data) -> str: + return yaml.dump(data, default_flow_style=False) + def formatBuildSet(self, data) -> str: # This is based on the web UI output = ''