diff --git a/doc/source/discussion/tenant-scoped-rest-api.rst b/doc/source/discussion/tenant-scoped-rest-api.rst index 601de6e7b4..0bf586ffde 100644 --- a/doc/source/discussion/tenant-scoped-rest-api.rst +++ b/doc/source/discussion/tenant-scoped-rest-api.rst @@ -8,9 +8,9 @@ Tenant Scoped REST API Users can perform some privileged actions at the tenant level through protected endpoints of the REST API, if these endpoints are activated. -The supported actions are **autohold**, **enqueue/enqueue-ref** and -**dequeue/dequeue-ref**. These are similar to the ones available through Zuul's -CLI. +The supported actions are **autohold**, **enqueue/enqueue-ref**, +**dequeue/dequeue-ref** and **promote**. These are similar to the ones available +through Zuul's CLI. The protected endpoints require a bearer token, passed to Zuul Web Server as the **Authorization** header of the request. The token and this workflow follow the diff --git a/doc/source/reference/client.rst b/doc/source/reference/client.rst index 4084c2c1a7..6939648df0 100644 --- a/doc/source/reference/client.rst +++ b/doc/source/reference/client.rst @@ -141,8 +141,6 @@ for these more advanced operations. Promote ^^^^^^^ -.. note:: This command is only available through a Gearman connection. - .. program-output:: zuul promote --help Example:: diff --git a/releasenotes/notes/admin_web_api_promote-b72d137af109341c.yaml b/releasenotes/notes/admin_web_api_promote-b72d137af109341c.yaml new file mode 100644 index 0000000000..bdd2e5c91b --- /dev/null +++ b/releasenotes/notes/admin_web_api_promote-b72d137af109341c.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + The action "promote" is now available via the tenant-scoped REST API using + the zuul cli. diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index 0cfba4d799..e4d0498d2f 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -2220,3 +2220,89 @@ class TestCLIViaWebApi(BaseTestWeb): self.executor_server.release() self.waitUntilSettled() self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1) + + def test_promote(self): + "Test that the RPC client can promote a change" + self.executor_server.hold_jobs_in_build = True + A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A') + B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B') + C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C') + A.addApproval('Code-Review', 2) + B.addApproval('Code-Review', 2) + C.addApproval('Code-Review', 2) + + self.fake_gerrit.addEvent(A.addApproval('Approved', 1)) + self.fake_gerrit.addEvent(B.addApproval('Approved', 1)) + self.fake_gerrit.addEvent(C.addApproval('Approved', 1)) + + self.waitUntilSettled() + + tenant = self.scheds.first.sched.abide.tenants.get('tenant-one') + items = tenant.layout.pipelines['gate'].getAllItems() + enqueue_times = {} + for item in items: + enqueue_times[str(item.change)] = item.enqueue_time + + # Promote B and C using the cli + 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') + p = subprocess.Popen( + [os.path.join(sys.prefix, 'bin/zuul'), + '--zuul-url', self.base_url, '--auth-token', token, + 'promote', '--tenant', 'tenant-one', + '--pipeline', 'gate', '--changes', '2,1', '3,1'], + stdout=subprocess.PIPE) + output = p.communicate() + self.assertEqual(p.returncode, 0, output[0]) + self.waitUntilSettled() + + # ensure that enqueue times are durable + items = tenant.layout.pipelines['gate'].getAllItems() + for item in items: + self.assertEqual( + enqueue_times[str(item.change)], item.enqueue_time) + + self.waitUntilSettled() + self.executor_server.release('.*-merge') + self.waitUntilSettled() + self.executor_server.release('.*-merge') + self.waitUntilSettled() + self.executor_server.release('.*-merge') + self.waitUntilSettled() + + self.assertEqual(len(self.builds), 6) + self.assertEqual(self.builds[0].name, 'project-test1') + self.assertEqual(self.builds[1].name, 'project-test2') + self.assertEqual(self.builds[2].name, 'project-test1') + self.assertEqual(self.builds[3].name, 'project-test2') + self.assertEqual(self.builds[4].name, 'project-test1') + self.assertEqual(self.builds[5].name, 'project-test2') + + self.assertTrue(self.builds[0].hasChanges(B)) + self.assertFalse(self.builds[0].hasChanges(A)) + self.assertFalse(self.builds[0].hasChanges(C)) + + self.assertTrue(self.builds[2].hasChanges(B)) + self.assertTrue(self.builds[2].hasChanges(C)) + self.assertFalse(self.builds[2].hasChanges(A)) + + self.assertTrue(self.builds[4].hasChanges(B)) + self.assertTrue(self.builds[4].hasChanges(C)) + self.assertTrue(self.builds[4].hasChanges(A)) + + self.executor_server.release() + self.waitUntilSettled() + + self.assertEqual(A.data['status'], 'MERGED') + self.assertEqual(A.reported, 2) + self.assertEqual(B.data['status'], 'MERGED') + self.assertEqual(B.reported, 2) + self.assertEqual(C.data['status'], 'MERGED') + self.assertEqual(C.reported, 2) diff --git a/zuul/cmd/client.py b/zuul/cmd/client.py index db535b888b..f9515b32aa 100755 --- a/zuul/cmd/client.py +++ b/zuul/cmd/client.py @@ -140,9 +140,19 @@ class ZuulRESTClient(object): self._check_status(req) return req.json() - def promote(self, *args, **kwargs): - raise NotImplementedError( - 'This action is unsupported by the REST API') + def promote(self, tenant, pipeline, change_ids): + if not self.auth_token: + raise Exception('Auth Token required') + args = { + "pipeline": pipeline, + "changes": change_ids, + } + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/promote' % tenant) + req = self.session.post(url, json=args) + self._check_status(req) + return req.json() def get_running_jobs(self, *args, **kwargs): raise NotImplementedError( diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py index 82362b08de..6f41a961b0 100755 --- a/zuul/web/__init__.py +++ b/zuul/web/__init__.py @@ -371,6 +371,42 @@ class ZuulWebAPI(object): resp.headers['Access-Control-Allow-Origin'] = '*' return result + @cherrypy.expose + @cherrypy.tools.json_in() + @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') + @cherrypy.tools.handle_options(allowed_methods=['POST', ]) + def promote(self, tenant): + basic_error = self._basic_auth_header_check() + if basic_error is not None: + return basic_error + if cherrypy.request.method != 'POST': + raise cherrypy.HTTPError(405) + # AuthN/AuthZ + claims, token_error = self._auth_token_check() + if token_error is not None: + return token_error + self.is_authorized(claims, tenant) + + body = cherrypy.request.json + pipeline = body.get('pipeline') + changes = body.get('changes') + + msg = 'User "%s" requesting "%s" on %s/%s' + self.log.info( + msg % (claims['__zuul_uid_claim'], 'promote', + tenant, pipeline)) + + job = self.rpc.submitJob('zuul:promote', + { + 'tenant': tenant, + 'pipeline': pipeline, + 'change_ids': changes, + }) + result = not job.failure + resp = cherrypy.response + resp.headers['Access-Control-Allow-Origin'] = '*' + return result + @cherrypy.expose @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') def autohold_list(self, tenant, *args, **kwargs): @@ -563,6 +599,7 @@ class ZuulWebAPI(object): 'autohold_delete': '/api/tenant/{tenant}/autohold/{request_id}', 'enqueue': '/api/tenant/{tenant}/project/{project:.*}/enqueue', 'dequeue': '/api/tenant/{tenant}/project/{project:.*}/dequeue', + 'promote': '/api/tenant/{tenant}/promote', } @cherrypy.expose @@ -1199,6 +1236,10 @@ class ZuulWeb(object): 'api', '/api/tenant/{tenant}/project/{project:.*}/dequeue', controller=api, action='dequeue') + route_map.connect( + 'api', + '/api/tenant/{tenant}/promote', + controller=api, action='promote') 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',