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
This commit is contained in:
Matthieu Huin 2020-09-10 19:56:59 +02:00
parent 4e4db91c13
commit 978340a694
8 changed files with 226 additions and 5 deletions

View File

@ -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
^^^^^^^

View File

@ -0,0 +1,5 @@
---
features:
- |
Add the **build-info** subcommand, allowing a user to fetch the details of
a given build by its UUID.

View File

@ -4,3 +4,4 @@ requests
setuptools
urllib3!=1.25.4,!=1.25.5 # https://github.com/urllib3/urllib3/pull/1684
PrettyTable
pyyaml

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

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