From 4931eed2b8dcdd9ec19be81cfcbdc422a2426ae1 Mon Sep 17 00:00:00 2001 From: David Shrewsbury Date: Wed, 28 Aug 2019 09:31:34 -0400 Subject: [PATCH] Add autohold delete/info commands to web API The autohold-delete and autohold-info CLI commands were added in earlier changes. This adds support for them to the web API. Change-Id: I2bfb3dbeb34221c978964afd63be536df9e11229 --- tests/unit/test_web.py | 116 ++++++++++++++++++++++++++++++++++++++++- zuul/web/__init__.py | 67 +++++++++++++++++++++++- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index 4253c9e2f4..32e2b15e09 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -89,6 +89,10 @@ class BaseTestWeb(ZuulTestCase): return requests.post( urllib.parse.urljoin(self.base_url, url), *args, **kwargs) + def delete_url(self, url, *args, **kwargs): + return requests.delete( + urllib.parse.urljoin(self.base_url, url), *args, **kwargs) + def tearDown(self): self.executor_server.hold_jobs_in_build = False self.executor_server.release() @@ -746,6 +750,44 @@ class TestWeb(BaseTestWeb): resp = self.get_url("api/tenant/non-tenant/status") self.assertEqual(404, resp.status_code) + def test_autohold_info_404_on_invalid_id(self): + resp = self.get_url("api/tenant/tenant-one/autohold/12345") + self.assertEqual(404, resp.status_code) + + def test_autohold_delete_404_on_invalid_id(self): + resp = self.delete_url("api/tenant/tenant-one/autohold/12345") + self.assertEqual(404, resp.status_code) + + def test_autohold_info(self): + client = zuul.rpcclient.RPCClient('127.0.0.1', + self.gearman_server.port) + self.addCleanup(client.shutdown) + r = client.autohold('tenant-one', 'org/project', 'project-test2', + "", "", "reason text", 1) + self.assertTrue(r) + + # Use autohold-list API to retrieve request ID + resp = self.get_url( + "api/tenant/tenant-one/autohold") + self.assertEqual(200, resp.status_code, resp.text) + autohold_requests = resp.json() + self.assertNotEqual([], autohold_requests) + self.assertEqual(1, len(autohold_requests)) + request_id = autohold_requests[0]['id'] + + # Now try the autohold-info API + resp = self.get_url("api/tenant/tenant-one/autohold/%s" % request_id) + self.assertEqual(200, resp.status_code, resp.text) + request = resp.json() + + self.assertEqual(request_id, request['id']) + self.assertEqual('tenant-one', request['tenant']) + self.assertIn('org/project', request['project']) + self.assertEqual('project-test2', request['job']) + self.assertEqual(".*", request['ref_filter']) + self.assertEqual(1, request['count']) + self.assertEqual("reason text", request['reason']) + def test_autohold_list(self): """test listing autoholds through zuul-web""" client = zuul.rpcclient.RPCClient('127.0.0.1', @@ -761,7 +803,6 @@ class TestWeb(BaseTestWeb): self.assertNotEqual([], autohold_requests) self.assertEqual(1, len(autohold_requests)) - # The single dict key should be a CSV string value ah_request = autohold_requests[0] self.assertEqual('tenant-one', ah_request['tenant']) @@ -784,7 +825,6 @@ class TestWeb(BaseTestWeb): self.assertNotEqual([], autohold_requests) self.assertEqual(1, len(autohold_requests)) - # The single dict key should be a CSV string value ah_request = autohold_requests[0] self.assertEqual('tenant-one', ah_request['tenant']) @@ -1265,6 +1305,38 @@ class TestTenantScopedWebApi(BaseTestWeb): 'pipeline': 'check'}) self.assertEqual(403, resp.status_code) + # For autohold-delete, we first must make sure that an autohold + # exists before the delete attempt. + good_authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'testuser', + 'zuul': {'admin': ['tenant-one', ]}, + 'exp': time.time() + 3600} + args = {"reason": "some reason", + "count": 1, + 'job': 'project-test2', + 'change': None, + 'ref': None, + 'node_hold_expiration': None} + good_token = jwt.encode(good_authz, key='NoDanaOnlyZuul', + algorithm='HS256').decode('utf-8') + req = self.post_url( + 'api/tenant/tenant-one/project/org/project/autohold', + headers={'Authorization': 'Bearer %s' % good_token}, + json=args) + self.assertEqual(200, req.status_code, req.text) + client = zuul.rpcclient.RPCClient('127.0.0.1', + self.gearman_server.port) + self.addCleanup(client.shutdown) + autohold_requests = client.autohold_list() + self.assertNotEqual([], autohold_requests) + self.assertEqual(1, len(autohold_requests)) + request = autohold_requests[0] + resp = self.delete_url( + "api/tenant/tenant-one/autohold/%s" % request['id'], + headers={'Authorization': 'Bearer %s' % token}) + self.assertEqual(403, resp.status_code) + def test_autohold(self): """Test that autohold can be set through the admin web interface""" args = {"reason": "some reason", @@ -1305,6 +1377,46 @@ class TestTenantScopedWebApi(BaseTestWeb): self.assertEqual("some reason", request['reason']) self.assertEqual(1, request['max_count']) + def test_autohold_delete(self): + authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'testuser', + 'zuul': { + 'admin': ['tenant-one', ] + }, + 'exp': time.time() + 3600} + token = jwt.encode(authz, key='NoDanaOnlyZuul', + algorithm='HS256').decode('utf-8') + + client = zuul.rpcclient.RPCClient('127.0.0.1', + self.gearman_server.port) + self.addCleanup(client.shutdown) + r = client.autohold('tenant-one', 'org/project', 'project-test2', + "", "", "reason text", 1) + self.assertTrue(r) + + # Use autohold-list API to retrieve request ID + resp = self.get_url( + "api/tenant/tenant-one/autohold") + self.assertEqual(200, resp.status_code, resp.text) + autohold_requests = resp.json() + self.assertNotEqual([], autohold_requests) + self.assertEqual(1, len(autohold_requests)) + request_id = autohold_requests[0]['id'] + + # now try the autohold-delete API + resp = self.delete_url( + "api/tenant/tenant-one/autohold/%s" % request_id, + headers={'Authorization': 'Bearer %s' % token}) + self.assertEqual(204, resp.status_code, resp.text) + + # autohold-list should be empty now + resp = self.get_url( + "api/tenant/tenant-one/autohold") + self.assertEqual(200, resp.status_code, resp.text) + autohold_requests = resp.json() + self.assertEqual([], autohold_requests) + def test_enqueue(self): """Test that the admin web interface can enqueue a change""" A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A') diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py index 4c5447365c..5ed0954a7c 100755 --- a/zuul/web/__init__.py +++ b/zuul/web/__init__.py @@ -422,7 +422,8 @@ class ZuulWebAPI(object): if (project is None or request['project'].endswith(project)): result.append( - {'tenant': request['tenant'], + {'id': request['id'], + 'tenant': request['tenant'], 'project': request['project'], 'job': request['job'], 'ref_filter': request['ref_filter'], @@ -432,6 +433,68 @@ class ZuulWebAPI(object): }) return result + @cherrypy.expose + @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') + def autohold_by_request_id(self, tenant, request_id): + if cherrypy.request.method == 'GET': + return self._autohold_info(request_id) + elif cherrypy.request.method == 'DELETE': + return self._autohold_delete(request_id) + else: + raise cherrypy.HTTPError(405) + + def _autohold_info(self, request_id): + job = self.rpc.submitJob('zuul:autohold_info', + {'request_id': request_id}) + if job.failure: + raise cherrypy.HTTPError(500, 'autohold-info failed') + else: + request = json.loads(job.data[0]) + if not request: + raise cherrypy.HTTPError( + 404, 'Hold request %s does not exist.' % request_id) + return { + 'id': request['id'], + 'tenant': request['tenant'], + 'project': request['project'], + 'job': request['job'], + 'ref_filter': request['ref_filter'], + 'count': request['max_count'], + 'reason': request['reason'], + 'node_hold_expiration': request['node_expiration'] + } + + def _autohold_delete(self, request_id): + # We need tenant info from the request for authz + request = self._autohold_info(request_id) + + basic_error = self._basic_auth_header_check() + if basic_error is not None: + return basic_error + # AuthN/AuthZ + rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):] + try: + claims = self.zuulweb.authenticators.authenticate(rawToken) + except exceptions.AuthTokenException as e: + for header, contents in e.getAdditionalHeaders().items(): + cherrypy.response.headers[header] = contents + cherrypy.response.status = e.HTTPError + return {'description': e.error_description, + 'error': e.error, + 'realm': e.realm} + self.is_authorized(claims, request['tenant']) + msg = 'User "%s" requesting "%s" on %s/%s' + self.log.info( + msg % (claims['__zuul_uid_claim'], 'autohold-delete', + request['tenant'], request['project'])) + + job = self.rpc.submitJob('zuul:autohold_delete', + {'request_id': request_id}) + if job.failure: + raise cherrypy.HTTPError(500, 'autohold-delete failed') + + cherrypy.response.status = 204 + @cherrypy.expose @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') def index(self): @@ -1037,6 +1100,8 @@ class ZuulWeb(object): 'api', '/api/tenant/{tenant}/project/{project:.*}/dequeue', controller=api, action='dequeue') + route_map.connect('api', '/api/tenant/{tenant}/autohold/{request_id}', + controller=api, action='autohold_by_request_id') route_map.connect('api', '/api/tenant/{tenant}/autohold', controller=api, action='autohold_list') route_map.connect('api', '/api/tenant/{tenant}/projects',