From f96ddd00fc69d8a4d51eb207ef322b99983d1fe8 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Sat, 16 Jul 2022 13:45:56 -0700 Subject: [PATCH] Add freeze-job subcommand This adds support for the freeze-job endpoint and displays information about a job as it would run in a pipeline. Because there is so much data, the text formatter only displays a high-level summary of the most interesting parts of a job. The JSON formatter returns everything. Change-Id: Ia3e00bf10eae0d569aa49773e81bc8bab1584ba7 --- doc/source/commands.rst | 21 ++++++++++ .../notes/freeze-job-8b2e86292952291e.yaml | 5 +++ tests/unit/test_api.py | 22 ++++++++++ tests/unit/test_cmd.py | 42 +++++++++++++++++++ zuulclient/api/__init__.py | 12 ++++++ zuulclient/cmd/__init__.py | 27 ++++++++++++ zuulclient/utils/formatters.py | 39 +++++++++++++++++ 7 files changed, 168 insertions(+) create mode 100644 releasenotes/notes/freeze-job-8b2e86292952291e.yaml diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 648b65b..eebd397 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -164,6 +164,27 @@ Note that zero values for ``oldrev`` and ``newrev`` can indicate branch creation and deletion; the source code of Zuul is the best reference for these more advanced operations. +Freeze-job +^^^^^^^^^^ + +Display information about a job as it would be run in a particular +project's pipeline. This causes Zuul to combine all of the matching +jobs and variants that would be used to form the final version of a +job that would be executed for a change or ref as enqueued into the +specified pipeline. This includes job attributes, playbook paths, +nodesets, variables, etc. Secret names may be included but the values +are redacted. + +The default text output shows an abbreviated summary of only the most +pertinent information. The JSON output reports all available +information. + +.. program-output:: zuul-client freeze-job --help + +Example:: + + zuul-client freeze-job --tenant mytenant --pipeline check --project org/project --branch master --job tox + Job-graph ^^^^^^^^^ diff --git a/releasenotes/notes/freeze-job-8b2e86292952291e.yaml b/releasenotes/notes/freeze-job-8b2e86292952291e.yaml new file mode 100644 index 0000000..10a7985 --- /dev/null +++ b/releasenotes/notes/freeze-job-8b2e86292952291e.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add freeze-job subcommand to display information about jobs as they would + be run in a project's pipeline. diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 583c37a..5cbdb39 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -488,3 +488,25 @@ GuS6/ewjS+arA1Iyeg/IxmECAwEAAQ== 'https://fake.zuul/api/tenant/tenant1/pipeline/check/' 'project/project1/branch/master/freeze-jobs') self.assertEqual(fakejson, graph) + + def test_freeze_job(self): + """Test freeze-job endpoint""" + client = ZuulRESTClient(url='https://fake.zuul/') + # test status checks + self._test_status_check( + client, 'get', client.freeze_jobs, + 'tenant1', 'check', 'project1', 'master') + + fakejson = { + "job": "testjob", + "ansible_version": "5", + } + req = FakeRequestResponse(200, fakejson) + client.session.get = MagicMock(return_value=req) + client.info_ = {} + job = client.freeze_job('tenant1', 'check', 'project1', 'master', + 'testjob') + client.session.get.assert_any_call( + 'https://fake.zuul/api/tenant/tenant1/pipeline/check/' + 'project/project1/branch/master/freeze-job/testjob') + self.assertEqual(fakejson, job) diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py index 81dd635..89b6d76 100644 --- a/tests/unit/test_cmd.py +++ b/tests/unit/test_cmd.py @@ -649,3 +649,45 @@ verify_ssl=True""" 'project/project1/branch/master/freeze-jobs', ) self.assertEqual(0, exit_code) + + def test_freeze_job(self): + """Test freeze-job subcommand""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + fakejson = { + "job": "testjob", + "ansible_version": "5", + "nodeset": { + "groups": [], + "name": "ubuntu-jammy", + }, + "vars": {}, + "pre_playbooks": [ + { + "branch": "master", + "connection": "gerrit", + "path": "playbooks/base/pre.yaml", + "project": "opendev/base-jobs", + "roles": [], + "trusted": True, + }, + ] + } + session.get = MagicMock( + side_effect=mock_get( + MagicMock(return_value=FakeRequestResponse(200, fakejson)) + ) + ) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', 'freeze-job', + '--tenant', 'tenant1', + '--pipeline', 'check', + '--project', 'project1', + '--branch', 'master', + '--job', 'testjob']) + session.get.assert_any_call( + 'https://fake.zuul/api/tenant/tenant1/pipeline/check/' + 'project/project1/branch/master/freeze-job/testjob', + ) + self.assertEqual(0, exit_code) diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py index 0263c59..4a052f3 100644 --- a/zuulclient/api/__init__.py +++ b/zuulclient/api/__init__.py @@ -305,3 +305,15 @@ class ZuulRESTClient(object): req = self.session.get(url) self._check_request_status(req) return req.json() + + def freeze_job(self, tenant, pipeline, project, branch, job): + suffix = (f'pipeline/{pipeline}/project/{project}/' + f'branch/{branch}/freeze-job/{job}') + if self.info.get("tenant"): + self._check_scope(tenant) + else: + suffix = f'tenant/{tenant}/{suffix}' + url = urllib.parse.urljoin(self.base_url, suffix) + req = self.session.get(url) + self._check_request_status(req) + return req.json() diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index 6defc59..62980e7 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -109,6 +109,7 @@ class ZuulClient(): self.add_builds_list_subparser(subparsers) self.add_build_info_subparser(subparsers) self.add_job_graph_subparser(subparsers) + self.add_freeze_job_subparser(subparsers) return subparsers @@ -814,6 +815,32 @@ class ZuulClient(): print(formatted_result) return True + def add_freeze_job_subparser(self, subparsers): + cmd_freeze_job = subparsers.add_parser( + 'freeze-job', help='Freeze and display a job') + cmd_freeze_job.add_argument( + '--tenant', help='tenant name', required=False, default='') + cmd_freeze_job.add_argument('--pipeline', help='pipeline name', + required=True) + cmd_freeze_job.add_argument('--project', help='project name', + required=True) + cmd_freeze_job.add_argument('--branch', help='branch name', + required=True) + cmd_freeze_job.add_argument('--job', help='job name', + required=True) + cmd_freeze_job.set_defaults(func=self.freeze_job) + self.cmd_freeze_job = cmd_freeze_job + + def freeze_job(self): + client = self.get_client() + self._check_tenant_scope(client) + job = client.freeze_job(self.tenant(), self.args.pipeline, + self.args.project, self.args.branch, + self.args.job) + formatted_result = self.formatter('FreezeJob')(job) + print(formatted_result) + return True + def main(): ZuulClient().main() diff --git a/zuulclient/utils/formatters.py b/zuulclient/utils/formatters.py index 2cb508d..8c1ebda 100644 --- a/zuulclient/utils/formatters.py +++ b/zuulclient/utils/formatters.py @@ -15,6 +15,7 @@ import time from dateutil.parser import isoparse +import pprint import prettytable import json @@ -66,6 +67,9 @@ class BaseFormatter: def formatJobGraph(self, data): raise NotImplementedError + def formatFreezeJob(self, data): + raise NotImplementedError + class JSONFormatter(BaseFormatter): def __call__(self, data) -> str: @@ -285,6 +289,41 @@ class PrettyTableFormatter(BaseFormatter): ]) return str(table) + def formatFreezeJob(self, data) -> str: + printer = pprint.PrettyPrinter(indent=2) + ret = '' + for label, key in [ + ('Job', 'job'), + ('Branch', 'branch'), + ('Ansible Version', 'ansible_version'), + ('Timeout', 'timeout'), + ('Post Timeout', 'post_timeout'), + ('Workspace Scheme', 'workspace_scheme'), + ('Override Checkout', 'override_checkout'), + ]: + value = data.get(key) + if value is not None: + ret += f'{label}: {value}\n' + if data['nodeset']['name']: + ret += f"Nodeset: {data['nodeset']['name']}\n" + for label, key in [ + ('Pre-run Playbooks', 'pre_playbooks'), + ('Run Playbooks', 'playbooks'), + ('Post-run Playbooks', 'post_playbooks'), + ('Cleanup Playbooks', 'cleanup_playbooks'), + ]: + pbs = data.get(key) + if not pbs: + continue + ret += f"{label}:\n" + for pb in pbs: + trusted = ' [trusted]' if pb['trusted'] else '' + ret += (f" {pb['connection']}:{pb['project']}:" + f"{pb['path']}@{pb['branch']}{trusted}\n") + ret += 'Vars:\n' + ret += printer.pformat(data['vars']) + return ret + class DotFormatter(BaseFormatter): """Format for graphviz"""