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 = ''