From 9105ffe00b2f63521e8512c50c0772046bab622e Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Thu, 7 Mar 2024 15:24:00 -0800 Subject: [PATCH] Add script to generate openapi spec The existing openapi spec document (used to generate the swagger ui page in the web app as well as the rst documentation) is both incomplete and wrong due to bitrot. This change adds a script which automatically generates much of the api documentation from the code. The output is still incomplete, but it does include at least the same endpoints currently documented, and of those, all of the inputs and outputs. Due to its automatic generation, all of the endpoints and their inputs are now documented. Only some outputs are missing (as well as explanatory text, which was pretty thin before). It does the following: * Inspects the cherrypy router object to determine the endpoints to include, and identifies their HTTP methods and the python functions that implement them. * It inspects the function python docstring to get summary documentation for the endpoint. * It inspects the function arguments and compares them to the router path to determine if each is a path or query parameter, as well as whether each is required. * It merges type and descriptive information from the python docstring about each parameter. * For output, a schema system similar to voluptuous is used to describe the output names and types, as well as optional descriptive information. One of two function decorators are used to describe the output. It removes the documentation for the status page output format. This API is specially optimized for the Zuul status page, is very complex, and we should therefore not encourage end-users to develop against it. The endpoint itself is documented as such, but the response value is undocumented. Future work: More descriptive text and output formats can be documented. Change-Id: Ib1a2aad728c4a7900841a8e3b617c146f2224953 --- tools/openapi_generate.py | 167 +++++ web/public/openapi.yaml | 1351 ++++++++++++++++++++++++------------- zuul/web/__init__.py | 759 ++++++++++++++------- 3 files changed, 1564 insertions(+), 713 deletions(-) create mode 100644 tools/openapi_generate.py diff --git a/tools/openapi_generate.py b/tools/openapi_generate.py new file mode 100644 index 0000000000..120e9130a3 --- /dev/null +++ b/tools/openapi_generate.py @@ -0,0 +1,167 @@ +# Copyright 2024 Acme Gating, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import re +import yaml + +from zuul.web import ZuulWeb, ZuulWebAPI + + +PARAM_RE = re.compile(':param (.*?) (.*?): (.*)') + +spec = { + 'info': { + 'title': 'Zuul REST API', + 'version': 'v1', + 'description': 'Incomplete (work in progress) list of the endpoints.', + }, + 'openapi': '3.0.0', + 'tags': [ + {'name': 'tenant'}, + ], + 'paths': {}, +} + + +def parse_docstring(doc): + # This separates the overall method summary (to be used as the + # endpoint summary in the openapi spec) from parameter types and + # descriptions. + summary = [] + params = {} + pname = None + ptype = None + pbuf = [] + for line in doc.split('\n'): + line = line.strip() + if not line: + continue + m = PARAM_RE.match(line) + if m: + if pname: + params[pname] = { + 'type': ptype, + 'desc': ' '.join(pbuf), + } + ptype, pname, pdesc = m.groups() + if ptype == 'str': + ptype = 'string' + pbuf = [pdesc.strip()] + continue + if pname: + pbuf.append(line.strip()) + else: + summary.append(line.strip()) + if pname: + params[pname] = { + 'type': ptype, + 'desc': ' '.join(pbuf), + } + return ' '.join(summary), params + + +def generate_spec(): + api = ZuulWebAPI + route_map = ZuulWeb.generateRouteMap(api, True) + # Iterate over each route + for r in route_map.mapper.matchlist: + # Some of our routes have globs in the variable names; remove + # those so that "{project:.*}" becomes "{project}" to match + # the function arguments. + routepath = r.routepath.replace(':.*', '') + # This is our output; initialize it to an empty dict, or if + # we're handling another instance of a previous route (for + # example, different GET and POST methods), start with that. + routespec = spec['paths'].setdefault(routepath, {}) + # action is the ZuulWebAPI method name + action = r.defaults['action'] + # handler is the ZuulWebAPI method itself + handler = getattr(api, action) + # Parse the docstring if available + doc = handler.__doc__ + if doc: + summary, doc_params = parse_docstring(doc) + else: + summary = '' + doc_params = {} + if r.conditions and r.conditions.get('method'): + # If this route specifies methods, use that + methods = r.conditions['method'] + else: + # Otherwise assume this method only handles GET + methods = ['GET'] + for method in methods: + if method == 'OPTIONS': + continue + # The @openapi decorators set this attribute; initialize + # the output dictionary for this route-method to that + # value or the empty dict. + default_methodspec = { + 'responses': { + '200': { + 'description': 'Response not yet documented', + } + } + } + methodspec = getattr(handler, '__openapi__', default_methodspec) + routespec[method.lower()] = methodspec + methodspec['summary'] = summary + methodspec['operationId'] = action + methodspec['tags'] = ['tenant'] + methodspec['parameters'] = [] + + # All inputs should be in the method signature, so iterate + # over that. + for handler_param in inspect.signature( + handler).parameters.values(): + paramspec = {} + # See if this function argument appears in the route + # path, if so, it's a "path" parameter, otherwise it's + # a "query" param. + in_path = '{' + handler_param.name + '}' in routepath + required = False + if handler_param.default is handler_param.empty: + # No default value; it's either in the path or we + # don't care about it. + if not in_path: + continue + required = True + paramspec = { + 'name': handler_param.name, + 'in': 'path' if in_path else 'query', + } + # Merge in information from the docstring if available. + doc_param = doc_params.get(handler_param.name) + if doc_param: + paramspec['description'] = doc_param['desc'] + paramspec['schema'] = {'type': doc_param['type']} + else: + paramspec['schema'] = {'type': 'string'} + if required: + paramspec['required'] = required + + methodspec['parameters'].append(paramspec) + return spec + + +def main(): + with open('web/public/openapi.yaml', 'w') as f: + f.write(yaml.safe_dump(generate_spec(), + default_flow_style=False)) + + +if __name__ == '__main__': + main() diff --git a/web/public/openapi.yaml b/web/public/openapi.yaml index cb2e10b37e..5562f223a9 100644 --- a/web/public/openapi.yaml +++ b/web/public/openapi.yaml @@ -1,18 +1,135 @@ info: + description: Incomplete (work in progress) list of the endpoints. title: Zuul REST API version: v1 - description: Incomplete (work in progress) list of the endpoints. openapi: 3.0.0 -tags: -- name: tenant paths: - /api/tenant/{tenant}/badge: + /api: get: - operationId: get-badge + operationId: index + parameters: [] + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/authorizations: + get: + operationId: root_authorizations + parameters: [] + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/components: + get: + operationId: components + parameters: [] + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/connections: + get: + operationId: connections + parameters: [] + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/info: + get: + operationId: info + parameters: [] + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/authorizations: + get: + operationId: tenant_authorizations + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/autohold: + get: + operationId: autohold_list + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/autohold/{request_id}: + delete: + operationId: autohold_delete + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: request_id + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + get: + operationId: autohold_get + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: request_id + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/badge: + get: + operationId: badge parameters: - description: The tenant name in: path - name: tenant + name: tenant_name required: true schema: type: string @@ -32,239 +149,554 @@ paths: schema: type: string responses: - '200': + 200: content: image/svg+xml: schema: - description: SVG image type: object description: Badge describing the result of the latest buildset found. - '404': + 404: description: No buildset found summary: Get a badge describing the result of the latest buildset found. tags: - tenant - /api/tenant/{tenant}/builds: + /api/tenant/{tenant_name}/build-times: get: - operationId: list-builds + operationId: build_times parameters: - - description: The tenant name - in: path - name: tenant + - in: path + name: tenant_name required: true schema: type: string - - description: A project name - in: query + - in: query name: project schema: type: string - - description: A pipeline name - in: query + - in: query name: pipeline schema: type: string - - description: A job name - in: query + - in: query + name: branch + schema: + type: string + - in: query + name: ref + schema: + type: string + - in: query name: job_name schema: type: string - - description: A branch name - in: query - name: branch + - in: query + name: final schema: type: string - - description: A change number - in: query - name: change + - in: query + name: start_time schema: type: string - - description: A patchset number - in: query - name: patchset + - in: query + name: end_time schema: type: string - - description: A ref - in: query - name: ref - schema: - type: string - - description: A new revision hash - in: query - name: newrev - schema: - type: string - - description: A build uuid - in: query - name: uuid - schema: - type: string - - description: A build voting status - in: query - name: voting - schema: - type: string - - description: A build result - in: query - name: result - schema: - type: string - - description: The limit count (default 50) - in: query + - in: query name: limit schema: type: string - - description: Skip number of results - in: query + - in: query name: skip schema: type: string + - in: query + name: exclude_result + schema: + type: string responses: '200': - content: - application/json: - schema: - description: The list of builds - items: - $ref: '#/components/schemas/build' - type: array - description: Returns the list of builds - '404': - description: Tenant not found - summary: List the executed builds + description: Response not yet documented + summary: '' tags: - tenant - /api/tenant/{tenant}/buildsets: + /api/tenant/{tenant_name}/build/{uuid}: get: - operationId: list-buildsets + operationId: build parameters: - - description: The tenant name - in: path - name: tenant + - in: path + name: tenant_name required: true schema: type: string - - description: A project name - in: query + - in: path + name: uuid + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/builds: + get: + operationId: builds + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: query name: project schema: type: string - - description: A pipeline name - in: query + - in: query name: pipeline schema: type: string - - description: A branch name - in: query - name: branch - schema: - type: string - - description: A change number - in: query + - in: query name: change schema: type: string - - description: A patchset number - in: query + - in: query + name: branch + schema: + type: string + - in: query name: patchset schema: type: string - - description: A ref - in: query + - in: query name: ref schema: type: string - - description: A new revision hash - in: query + - in: query name: newrev schema: type: string - - description: A buildset uuid - in: query + - in: query name: uuid schema: type: string - - description: A buildset result - in: query + - in: query + name: job_name + schema: + type: string + - in: query + name: voting + schema: + type: string + - in: query + name: nodeset + schema: + type: string + - in: query name: result schema: type: string - - description: The limit count (default 50) - in: query + - in: query + name: final + schema: + type: string + - in: query + name: held + schema: + type: string + - in: query + name: complete + schema: + type: string + - in: query name: limit schema: type: string - - description: Skip number of results - in: query + - in: query + name: skip + schema: + type: string + - in: query + name: idx_min + schema: + type: string + - in: query + name: idx_max + schema: + type: string + - in: query + name: exclude_result + schema: + type: string + responses: + 200: + content: + application/json: + schema: + items: + description: The build + properties: + _id: + type: string + artifacts: + items: + properties: + metadata: + type: object + name: + type: string + url: + type: string + type: object + type: array + duration: + type: string + end_time: + type: string + error_detail: + type: string + final: + type: string + held: + type: string + job_name: + type: string + log_url: + type: string + nodeset: + type: string + provides: + items: + properties: + name: + type: string + type: object + type: array + ref: + description: The ref + properties: + branch: + type: string + change: + type: string + newrev: + type: string + oldrev: + type: string + patchset: + type: string + project: + type: string + ref: + type: string + ref_url: + type: string + type: object + result: + type: string + start_time: + type: string + uuid: + type: string + voting: + type: string + type: object + type: array + description: Returns the list of builds + 404: + description: Tenant not found + summary: List the executed builds + tags: + - tenant + /api/tenant/{tenant_name}/buildset/{uuid}: + get: + operationId: buildset + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: uuid + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/buildsets: + get: + operationId: buildsets + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: query + name: project + schema: + type: string + - in: query + name: pipeline + schema: + type: string + - in: query + name: change + schema: + type: string + - in: query + name: branch + schema: + type: string + - in: query + name: patchset + schema: + type: string + - in: query + name: ref + schema: + type: string + - in: query + name: newrev + schema: + type: string + - in: query + name: uuid + schema: + type: string + - in: query + name: result + schema: + type: string + - in: query + name: complete + schema: + type: string + - in: query + name: limit + schema: + type: string + - in: query + name: skip + schema: + type: string + - in: query + name: idx_min + schema: + type: string + - in: query + name: idx_max + schema: + type: string + responses: + 200: + content: + application/json: + schema: + items: + description: The buildset + properties: + _id: + type: string + event_id: + type: string + event_timestamp: + type: string + first_build_start_time: + type: string + last_build_end_time: + type: string + message: + type: string + pipeline: + type: string + refs: + items: + description: The ref + properties: + branch: + type: string + change: + type: string + newrev: + type: string + oldrev: + type: string + patchset: + type: string + project: + type: string + ref: + type: string + ref_url: + type: string + type: object + type: array + result: + type: string + uuid: + type: string + type: object + type: array + description: Returns the list of buildsets + 404: + description: Tenant not found + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/config-errors: + get: + operationId: config_errors + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: query + name: project + schema: + type: string + - in: query + name: branch + schema: + type: string + - in: query + name: severity + schema: + type: string + - in: query + name: name + schema: + type: string + - in: query + name: limit + schema: + type: string + - in: query name: skip schema: type: string responses: '200': - content: - application/json: - schema: - description: The list of buildsets - items: - $ref: '#/components/schemas/buildset' - type: array - description: Returns the list of builds - '404': - description: Tenant not found - summary: List the executed builds + description: Response not yet documented + summary: '' tags: - tenant - /api/tenant/{tenant}/jobs: + /api/tenant/{tenant_name}/console-stream: get: - operationId: list-jobs + operationId: console_stream_get parameters: - - description: The tenant name - in: path - name: tenant + - in: path + name: tenant_name required: true schema: type: string responses: '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/info: + get: + operationId: tenant_info + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/job/{job_name}: + get: + operationId: job + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: job_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/jobs: + get: + operationId: jobs + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + 200: content: application/json: schema: - description: The list of jobs items: - description: A job properties: description: - description: The job short description type: string name: - description: The job name type: string + tags: + items: + type: string + type: array + variants: + items: + properties: + branches: + items: + type: string + type: array + parent: + type: string + type: object + type: array type: object type: array description: Returns the list of jobs - '404': + 404: description: Tenant not found - summary: List available jobs + summary: '' tags: - tenant - /api/tenant/{tenant}/key/{project}.pub: + /api/tenant/{tenant_name}/key/{project_name}.pub: get: - operationId: get-project-secrets-key + operationId: key parameters: - - description: The tenant name - in: path - name: tenant + - in: path + name: tenant_name required: true schema: type: string - - description: The project name - in: path - name: project + - in: path + name: project_name required: true schema: type: string responses: - '200': + 200: content: text/plain: example: '-----BEGIN PUBLIC KEY----- @@ -275,114 +707,354 @@ paths: ' schema: - description: The project secrets public key in PKCS8 format type: string description: Returns the project public key that is used to encrypt secrets - '404': + 404: description: Tenant or Project not found - summary: Get a project public key that is used to encrypt secrets + summary: '' tags: - tenant - /api/tenant/{tenant}/project-ssh-key/{project}.pub: + /api/tenant/{tenant_name}/labels: get: - operationId: get-project-ssh-key + operationId: labels parameters: - - description: The tenant name - in: path - name: tenant - required: true - schema: - type: string - - description: The project name - in: path - name: project + - in: path + name: tenant_name required: true schema: type: string responses: '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/nodes: + get: + operationId: nodes + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/pipeline/{pipeline_name}/project/{project_name}/branch/{branch_name}/freeze-job/{job_name}: + get: + operationId: project_freeze_job + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: pipeline_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + - in: path + name: branch_name + required: true + schema: + type: string + - in: path + name: job_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/pipeline/{pipeline_name}/project/{project_name}/branch/{branch_name}/freeze-jobs: + get: + operationId: project_freeze_jobs + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: pipeline_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + - in: path + name: branch_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/pipelines: + get: + operationId: pipelines + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/project-ssh-key/{project_name}.pub: + get: + operationId: project_ssh_key + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + responses: + 200: content: text/plain: - example: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACA - - ' + example: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACA schema: - description: The project ssh public key in SSH2 format type: string description: Returns the project public key that executor adds to SSH agent - '404': + 404: description: Tenant or Project not found - summary: Get a project public key that is used for SSH in post-merge pipelines + summary: '' tags: - tenant - /api/tenant/{tenant}/semaphores: + /api/tenant/{tenant_name}/project/{project_name}: get: - operationId: list-semaphores + operationId: project parameters: - - description: The tenant name - in: path - name: tenant + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: project_name required: true schema: type: string responses: '200': - content: - application/json: - schema: - description: The list of semaphores - items: - $ref: '#/components/schemas/semaphore' - type: array + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/project/{project_name}/autohold: + get: + operationId: autohold_project_get + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + post: + operationId: autohold_project_post + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/project/{project_name}/dequeue: + get: + operationId: dequeue + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/project/{project_name}/enqueue: + get: + operationId: enqueue + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: project_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/projects: + get: + operationId: projects + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/promote: + get: + operationId: promote + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' + tags: + - tenant + /api/tenant/{tenant_name}/semaphores: + get: + operationId: semaphores + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + 200: description: Returns the list of semaphores - '404': + 404: description: Tenant not found - summary: List available semaphores + summary: '' tags: - tenant - /api/tenant/{tenant}/status: + /api/tenant/{tenant_name}/status: get: - operationId: get-status + operationId: status parameters: - - description: The tenant name - in: path - name: tenant + - in: path + name: tenant_name required: true schema: type: string responses: '200': - content: - application/json: - schema: - description: The tenant status - properties: - last_reconfigured: - description: The Epoch timestamp of the last reconfiguration - type: integer - pipelines: - description: The list of pipelines - items: - $ref: '#/components/schemas/pipelineStatus' - type: array - trigger_event_queue: - description: The number of running events - type: integer - zuul_version: - description: The Zuul version - type: string - type: object - description: Returns the list of tenants - '404': - description: Tenant not found - summary: Get tenant status + description: Response not yet documented + summary: 'Return the tenant status. Note: the output format is not currently + documented and subject to change without notice.' + tags: + - tenant + /api/tenant/{tenant_name}/status/change/{change}: + get: + operationId: status_change + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + - in: path + name: change + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: 'Return the status for a single change. Note: the output format is + not currently documented and subject to change without notice.' + tags: + - tenant + /api/tenant/{tenant_name}/tenant-status: + get: + operationId: tenant_status + parameters: + - in: path + name: tenant_name + required: true + schema: + type: string + responses: + '200': + description: Response not yet documented + summary: '' tags: - tenant /api/tenants: get: - operationId: list-tenants + operationId: tenants + parameters: [] responses: - '200': + 200: content: application/json: schema: @@ -400,289 +1072,10 @@ paths: type: object type: array description: Returns the list of tenants - summary: List the tenants + 404: + description: Tenant not found + summary: '' tags: - tenant -components: - schemas: - build: - description: A build - properties: - branch: - description: The build change branch - type: string - change: - description: The build change number - type: integer - duration: - description: The build duration in second - type: integer - end_time: - description: The build end time ISO 8601 format - type: string - job_name: - description: The build job name - type: string - log_url: - description: The build logs url - type: string - newrev: - description: The build newrev - type: string - patchset: - description: The build patchset number - type: integer - pipeline: - description: The build pipeline - type: string - project: - description: The build project name - type: string - ref: - description: The build change reference - type: string - ref_url: - description: The build change reference url - type: string - result: - description: The build result - type: string - start_time: - description: The build start time ISO 8601 format - type: string - uuid: - description: The build uuid - type: string - voting: - description: The build voting status - type: boolean - type: object - buildset: - description: A buildset - properties: - project: - description: The buildset project name - type: string - event_id: - description: The event id that triggered the buildset - type: string - uuid: - description: The buildset uuid - type: string - branch: - description: The buildset change branch - type: string - ref_url: - description: The buildset change reference url - type: string - newrev: - description: The buildset newrev - type: string - result: - description: The buildset result - type: string - change: - description: The buildset change number - type: integer - patchset: - description: The buildset patchset number - type: integer - ref: - description: The buildset change reference - type: string - pipeline: - description: The buildset pipeline - type: string - message: - description: The message that got reported by the buildset - type: object - changeQueue: - description: A change queue - properties: - heads: - description: The list of queue events - items: - items: - $ref: '#/components/schemas/eventQueue' - type: array - type: array - name: - description: The queue name - type: string - window: - description: The queue window - type: integer - type: object - eventQueue: - description: A queue event - properties: - active: - description: Is the event active - type: boolean - enqueue_time: - description: The epoch creation time - type: integer - failing_reasons: - description: The list of failing reasons - items: - type: string - type: array - id: - description: The event id - type: string - item_ahead: - description: The event ahead - type: string - items_behind: - description: The list of events behind - items: - type: string - type: array - jobs: - description: The event list of jobs - items: - $ref: '#/components/schemas/statusJob' - type: array - live: - description: Is the event live - type: boolean - owner: - description: The event owner - type: string - project: - description: The event project - type: string - remaining_time: - description: The estimated remaining time - type: integer - url: - description: The event url - type: string - zuul_ref: - description: The zuul ref - type: string - type: object - pipelineStatus: - description: A pipeline - properties: - change_queues: - description: The pipeline list of change queues - items: - $ref: '#/components/schemas/changeQueue' - type: array - description: - description: The pipeline description - type: string - name: - description: The pipeline name - type: string - type: object - semaphore: - description: A semaphore - properties: - name: - description: The semaphore name - type: string - global: - description: Whether the semaphore is global - type: boolean - max: - description: The maximum number of holders - type: integer - holders: - $ref: '#/components/schemas/semaphoreHolders' - type: object - semaphoreHolders: - description: Information about the holders of a semaphore - properties: - count: - description: The number of jobs currently holding this semaphore - type: integer - this_tenant: - description: Holders within this tenant - items: - $ref: '#/components/schemas/semaphoreHolder' - type: array - other_tenants: - description: The number of jobs in other tenants currently holding this semaphore - type: integer - type: object - semaphoreHolder: - description: Information about a holder of a semaphore - properties: - buildset_uuid: - description: The UUID of the job's buildset - type: string - job_name: - description: The name of the job - type: string - type: object - statusJob: - description: A job status - properties: - canceled: - description: Is the job canceled - type: boolean - elapsed_time: - description: The elapsed time - type: integer - launch_time: - description: The epoch launch time - type: integer - name: - description: The job name - type: string - number: - description: The job number - type: integer - pipeline: - description: The job pipeline - type: string - remaining_time: - description: The estimated remaining time - type: integer - result: - description: The job result - type: string - retry: - description: The retry count - type: integer - start_time: - description: The epoch start time - type: integer - url: - description: The job stream url - type: string - uuid: - description: The job uuid - type: string - voting: - description: Is the job voting - type: boolean - worker: - $ref: '#/components/schemas/statusWorker' - type: object - statusWorker: - description: A job worker node - properties: - fqdn: - description: The worker fully qualified domain name - type: string - hostname: - description: The worker hostname - type: string - ips: - description: The list of worker ip addresses - items: - type: string - type: array - name: - description: The worker name - type: string - program: - description: The worker program - type: string - version: - description: The worker version - type: string - type: object +tags: +- name: tenant diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py index 01424e5d2d..a29f891f27 100755 --- a/zuul/web/__init__.py +++ b/zuul/web/__init__.py @@ -34,6 +34,7 @@ import threading import uuid import prometheus_client import urllib.parse +import types import zuul.executor.common from zuul import exceptions @@ -108,6 +109,241 @@ def get_request_logger(logger=None): return get_annotated_logger(logger, None, request=zuul_request_id) +def _datetimeToString(my_datetime): + if my_datetime: + return my_datetime.strftime('%Y-%m-%dT%H:%M:%S') + return None + + +class Prop: + def __init__(self, description, value): + """A property of an OpenAPI schema. + + :param str description: The description of this property + :param type value: The type of the property; either a native + Python scalar type, Prop instance, or list or dictionary of + the preceding. + """ + self.description = description + self.value = value + + @staticmethod + def _toOpenAPI(value): + ret = {} + if isinstance(value, Prop): + ret['description'] = value.description + value = value.value + if isinstance(value, (list, tuple)): + ret['type'] = 'array' + ret['items'] = Prop._toOpenAPI(value[0]) + elif isinstance(value, (types.MappingProxyType, dict)): + ret['type'] = 'object' + ret['properties'] = { + k: Prop._toOpenAPI(v) for (k, v) in value.items() + } + elif value is str: + ret['type'] = 'string' + elif value is int: + ret['type'] = 'integer' + elif value is float: + ret['type'] = 'number' + elif value is bool: + ret['type'] = 'boolean' + elif value is dict: + ret['type'] = 'object' + elif value is object: + ret['type'] = 'object' + return ret + + def toOpenAPI(self): + "Convert this Prop to an OpenAPI schema." + return Prop._toOpenAPI(self.value) + + +class RefConverter: + # A class to encapsulate the conversion of database Ref objects to + # API output. + @staticmethod + def toDict(ref): + return { + 'project': ref.project, + 'branch': ref.branch, + 'change': ref.change, + 'patchset': ref.patchset, + 'ref': ref.ref, + 'oldrev': ref.oldrev, + 'newrev': ref.newrev, + 'ref_url': ref.ref_url, + } + + @staticmethod + def schema(): + return Prop('The ref', { + 'project': str, + 'branch': str, + 'change': str, + 'patchset': str, + 'ref': str, + 'oldrev': str, + 'newrev': str, + 'ref_url': str, + }) + + +class BuildConverter: + # A class to encapsulate the conversion of database Build objects to + # API output. + def toDict(build, buildset=None, skip_refs=False): + start_time = _datetimeToString(build.start_time) + end_time = _datetimeToString(build.end_time) + if build.start_time and build.end_time: + duration = (build.end_time - + build.start_time).total_seconds() + else: + duration = None + + ret = { + '_id': build.id, + 'uuid': build.uuid, + 'job_name': build.job_name, + 'result': build.result, + 'held': build.held, + 'start_time': start_time, + 'end_time': end_time, + 'duration': duration, + 'voting': build.voting, + 'log_url': build.log_url, + 'nodeset': build.nodeset, + 'error_detail': build.error_detail, + 'final': build.final, + 'artifacts': [], + 'provides': [], + 'ref': RefConverter.toDict(build.ref), + } + if buildset: + # We enter this branch if we're returning top-level build + # objects (ie, not builds under a buildset). + event_timestamp = _datetimeToString(buildset.event_timestamp) + ret.update({ + 'pipeline': buildset.pipeline, + 'event_id': buildset.event_id, + 'event_timestamp': event_timestamp, + 'buildset': { + 'uuid': buildset.uuid, + }, + }) + if not skip_refs: + ret['buildset']['refs'] = [ + RefConverter.toDict(ref) + for ref in buildset.refs + ] + + for artifact in build.artifacts: + art = { + 'name': artifact.name, + 'url': artifact.url, + } + if artifact.meta: + art['metadata'] = json.loads(artifact.meta) + ret['artifacts'].append(art) + for provides in build.provides: + ret['provides'].append({ + 'name': provides.name, + }) + return ret + + @staticmethod + def schema(buildset=False, skip_refs=False): + ret = { + '_id': str, + 'uuid': str, + 'job_name': str, + 'result': str, + 'held': str, + 'start_time': str, + 'end_time': str, + 'duration': str, + 'voting': str, + 'log_url': str, + 'nodeset': str, + 'error_detail': str, + 'final': str, + 'artifacts': [{ + 'name': str, + 'url': str, + 'metadata': dict, + }], + 'provides': [{ + 'name': str, + }], + 'ref': RefConverter.schema(), + } + if buildset: + # We enter this branch if we're returning top-level build + # objects (ie, not builds under a buildset). + ret.update({ + 'pipeline': str, + 'event_id': str, + 'event_timestamp': str, + 'buildset': { + 'uuid': str, + }, + }) + if not skip_refs: + ret['buildset']['refs'] = [RefConverter.schema()] + + ret = Prop('The build', ret) + return ret + + +class BuildsetConverter: + # A class to encapsulate the conversion of database Buildset + # objects to API output. + def toDict(buildset, builds=[]): + event_timestamp = _datetimeToString(buildset.event_timestamp) + start = _datetimeToString(buildset.first_build_start_time) + end = _datetimeToString(buildset.last_build_end_time) + ret = { + '_id': buildset.id, + 'uuid': buildset.uuid, + 'result': buildset.result, + 'message': buildset.message, + 'pipeline': buildset.pipeline, + 'event_id': buildset.event_id, + 'event_timestamp': event_timestamp, + 'first_build_start_time': start, + 'last_build_end_time': end, + 'refs': [ + RefConverter.toDict(ref) + for ref in buildset.refs + ], + } + if builds: + ret['builds'] = [] + for build in builds: + ret['builds'].append(BuildConverter.toDict(build)) + return ret + + def schema(builds=False): + ret = { + '_id': str, + 'uuid': str, + 'result': str, + 'message': str, + 'pipeline': str, + 'event_id': str, + 'event_timestamp': str, + 'first_build_start_time': str, + 'last_build_end_time': str, + 'refs': [ + RefConverter.schema() + ], + } + if builds: + ret['builds'] = [BuildConverter.schema()] + return Prop('The buildset', ret) + + class APIError(cherrypy.HTTPError): def __init__(self, code, json_doc=None, headers=None): self._headers = headers or {} @@ -179,6 +415,42 @@ cherrypy.tools.handle_options = cherrypy.Tool('on_start_resource', priority=50) +def openapi_response( + code, + description=None, + content_type=None, + example=None, + schema=None, +): + """Describe an OpenAPI response + + :param int code: The HTTP response code + :param str description: A description for the response + :param str content_type: The HTTP content type + :param str example: An example of the response output + :param Prop schema: A Prop describing the returned schema + """ + response_spec = {} + if description: + response_spec['description'] = description + if content_type: + content_spec = {} + response_spec['content'] = {content_type: content_spec} + if example: + content_spec['example'] = example + if schema: + content_spec['schema'] = schema.toOpenAPI() + + def decorator(func): + if not hasattr(func, '__openapi__'): + func.__openapi__ = {} + func.__openapi__.setdefault('responses', {}) + func.__openapi__['responses'][code] = response_spec + return func + + return decorator + + class AuthInfo: def __init__(self, uid, admin): self.uid = uid @@ -1069,6 +1341,17 @@ class ZuulWebAPI(object): @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') @cherrypy.tools.handle_options() @cherrypy.tools.check_root_auth() + @openapi_response( + code=200, + content_type='application/json', + description='Returns the list of tenants', + schema=Prop('The list of tenants', [{ + 'name': Prop('Tenant name', str), + 'projects': Prop('Tenant project count', int), + 'queue': Prop('Active changes count', int), + }]), + ) + @openapi_response(404, description='Tenant not found') def tenants(self, auth): cache_time = self.tenants_cache_time if time.time() - cache_time > self.cache_expiry: @@ -1204,6 +1487,12 @@ class ZuulWebAPI(object): @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() def status(self, tenant_name, tenant, auth): + """Return the tenant status. + + Note: the output format is not currently documented and + subject to change without notice. + + """ return self._getStatus(tenant)[1] @cherrypy.expose @@ -1212,6 +1501,12 @@ class ZuulWebAPI(object): @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() def status_change(self, tenant_name, tenant, auth, change): + """Return the status for a single change. + + Note: the output format is not currently documented and + subject to change without notice. + + """ payload = self._getStatus(tenant)[0] result_filter = RefFilter(change) return result_filter.filterPayload(payload) @@ -1223,6 +1518,21 @@ class ZuulWebAPI(object): ) @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + content_type='application/json', + description='Returns the list of jobs', + schema=Prop('The list of jobs', [{ + 'name': str, + 'description': str, + 'tags': [str], + 'variants': [{ + 'parent': str, + 'branches': [str], + }], + }]), + ) + @openapi_response(404, description='Tenant not found') def jobs(self, tenant_name, tenant, auth): result = [] for job_name in sorted(tenant.layout.jobs): @@ -1435,6 +1745,18 @@ class ZuulWebAPI(object): @cherrypy.tools.save_params() @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + content_type='text/plain', + description=('Returns the project public key that is used ' + 'to encrypt secrets'), + example=('-----BEGIN PUBLIC KEY-----\n' + 'MIICI...\n' + '-----END PUBLIC KEY-----\n'), + schema=Prop('The project secrets public key ' + 'in PKCS8 format', str) + ) + @openapi_response(404, 'Tenant or Project not found') def key(self, tenant_name, tenant, auth, project_name): project = self._getProjectOrRaise(tenant, project_name) @@ -1447,6 +1769,16 @@ class ZuulWebAPI(object): @cherrypy.tools.save_params() @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + content_type='text/plain', + description=('Returns the project public key that executor ' + 'adds to SSH agent'), + example='ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACA', + schema=Prop('The project secrets public key ' + 'in SSH2 format', str), + ) + @openapi_response(404, 'Tenant or Project not found') def project_ssh_key(self, tenant_name, tenant, auth, project_name): project = self._getProjectOrRaise(tenant, project_name) @@ -1455,82 +1787,6 @@ class ZuulWebAPI(object): resp.headers['Content-Type'] = 'text/plain' return key - def _datetimeToString(self, my_datetime): - if my_datetime: - return my_datetime.strftime('%Y-%m-%dT%H:%M:%S') - return None - - def refToDict(self, ref): - return { - 'project': ref.project, - 'branch': ref.branch, - 'change': ref.change, - 'patchset': ref.patchset, - 'ref': ref.ref, - 'oldrev': ref.oldrev, - 'newrev': ref.newrev, - 'ref_url': ref.ref_url, - } - - def buildToDict(self, build, buildset=None, skip_refs=False): - start_time = self._datetimeToString(build.start_time) - end_time = self._datetimeToString(build.end_time) - if build.start_time and build.end_time: - duration = (build.end_time - - build.start_time).total_seconds() - else: - duration = None - - ret = { - '_id': build.id, - 'uuid': build.uuid, - 'job_name': build.job_name, - 'result': build.result, - 'held': build.held, - 'start_time': start_time, - 'end_time': end_time, - 'duration': duration, - 'voting': build.voting, - 'log_url': build.log_url, - 'nodeset': build.nodeset, - 'error_detail': build.error_detail, - 'final': build.final, - 'artifacts': [], - 'provides': [], - 'ref': self.refToDict(build.ref), - } - if buildset: - # We enter this branch if we're returning top-level build - # objects (ie, not builds under a buildset). - event_timestamp = self._datetimeToString(buildset.event_timestamp) - ret.update({ - 'pipeline': buildset.pipeline, - 'event_id': buildset.event_id, - 'event_timestamp': event_timestamp, - 'buildset': { - 'uuid': buildset.uuid, - }, - }) - if not skip_refs: - ret['buildset']['refs'] = [ - self.refToDict(ref) - for ref in buildset.refs - ] - - for artifact in build.artifacts: - art = { - 'name': artifact.name, - 'url': artifact.url, - } - if artifact.meta: - art['metadata'] = json.loads(artifact.meta) - ret['artifacts'].append(art) - for provides in build.provides: - ret['provides'].append({ - 'name': provides.name, - }) - return ret - def _get_connection(self): return self.zuulweb.connections.connections['database'] @@ -1539,12 +1795,23 @@ class ZuulWebAPI(object): @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + content_type='application/json', + description='Returns the list of builds', + schema=Prop('The list of builds', [BuildConverter.schema()]), + ) + @openapi_response(404, 'Tenant not found') def builds(self, tenant_name, tenant, auth, project=None, pipeline=None, change=None, branch=None, patchset=None, ref=None, newrev=None, uuid=None, job_name=None, voting=None, nodeset=None, result=None, final=None, held=None, complete=None, limit=50, skip=0, idx_min=None, idx_max=None, exclude_result=None): + """ + List the executed builds + """ + connection = self._get_connection() if tenant_name not in self.zuulweb.abide.tenants.keys(): @@ -1574,7 +1841,7 @@ class ZuulWebAPI(object): idx_max=_idx_max, exclude_result=exclude_result, query_timeout=self.query_timeout) - return [self.buildToDict(b, b.buildset, skip_refs=True) + return [BuildConverter.toDict(b, b.buildset, skip_refs=True) for b in builds] @cherrypy.expose @@ -1588,12 +1855,12 @@ class ZuulWebAPI(object): data = connection.getBuild(tenant_name, uuid) if not data: raise cherrypy.HTTPError(404, "Build not found") - data = self.buildToDict(data, data.buildset) + data = BuildConverter.toDict(data, data.buildset) return data def buildTimeToDict(self, build): - start_time = self._datetimeToString(build.start_time) - end_time = self._datetimeToString(build.end_time) + start_time = _datetimeToString(build.start_time) + end_time = _datetimeToString(build.end_time) if build.start_time and build.end_time: duration = (build.end_time - build.start_time).total_seconds() @@ -1652,37 +1919,31 @@ class ZuulWebAPI(object): return [self.buildTimeToDict(b) for b in build_times] - def buildsetToDict(self, buildset, builds=[]): - event_timestamp = self._datetimeToString(buildset.event_timestamp) - start = self._datetimeToString(buildset.first_build_start_time) - end = self._datetimeToString(buildset.last_build_end_time) - ret = { - '_id': buildset.id, - 'uuid': buildset.uuid, - 'result': buildset.result, - 'message': buildset.message, - 'pipeline': buildset.pipeline, - 'event_id': buildset.event_id, - 'event_timestamp': event_timestamp, - 'first_build_start_time': start, - 'last_build_end_time': end, - 'refs': [ - self.refToDict(ref) - for ref in buildset.refs - ], - } - if builds: - ret['builds'] = [] - for build in builds: - ret['builds'].append(self.buildToDict(build)) - return ret - @cherrypy.expose @cherrypy.tools.save_params() @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + content_type='image/svg+xml', + description=('Badge describing the result of ' + 'the latest buildset found.'), + schema=Prop('SVG image', object), + ) + @openapi_response(404, 'No buildset found') def badge(self, tenant_name, tenant, auth, project=None, pipeline=None, branch=None): + """ + Get a badge describing the result of the latest buildset found. + + :param str tenant_name: The tenant name + :param Tenant tenant: The tenant object + :param AuthInfo auth: The auth object + :param str project: A project name + :param str pipeline: A pipeline name + :param str branch: A branch name + """ + connection = self._get_connection() buildsets = connection.getBuildsets( @@ -1709,6 +1970,13 @@ class ZuulWebAPI(object): @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + content_type='application/json', + description='Returns the list of buildsets', + schema=Prop('The list of buildsets', [BuildsetConverter.schema()]), + ) + @openapi_response(404, 'Tenant not found') def buildsets(self, tenant_name, tenant, auth, project=None, pipeline=None, change=None, branch=None, patchset=None, ref=None, newrev=None, uuid=None, @@ -1732,7 +2000,7 @@ class ZuulWebAPI(object): limit=limit, offset=skip, idx_min=_idx_min, idx_max=_idx_max, query_timeout=self.query_timeout) - return [self.buildsetToDict(b) for b in buildsets] + return [BuildsetConverter.toDict(b) for b in buildsets] @cherrypy.expose @cherrypy.tools.save_params() @@ -1745,7 +2013,7 @@ class ZuulWebAPI(object): data = connection.getBuildset(tenant_name, uuid) if not data: raise cherrypy.HTTPError(404, "Buildset not found") - data = self.buildsetToDict(data, data.builds) + data = BuildsetConverter.toDict(data, data.builds) return data @cherrypy.expose @@ -1755,6 +2023,24 @@ class ZuulWebAPI(object): ) @cherrypy.tools.handle_options() @cherrypy.tools.check_tenant_auth() + @openapi_response( + code=200, + description='Returns the list of semaphores', + schema=Prop('The list of semaphores', [{ + 'name': str, + 'global': bool, + 'max': int, + 'holders': { + 'count': int, + 'other_tenants': int, + 'this_tenant': [{ + 'buildset_uuid': str, + 'job_name': str, + }], + }, + }]), + ) + @openapi_response(404, 'Tenant not found') def semaphores(self, tenant_name, tenant, auth): result = [] names = set(tenant.layout.semaphores.keys()) @@ -2022,6 +2308,132 @@ class ZuulWeb(object): log = logging.getLogger("zuul.web") tracer = trace.get_tracer("zuul") + @staticmethod + def generateRouteMap(api, include_auth): + route_map = cherrypy.dispatch.RoutesDispatcher() + route_map.connect('api', '/api', + controller=api, action='index') + route_map.connect('api', '/api/info', + controller=api, action='info') + route_map.connect('api', '/api/connections', + controller=api, action='connections') + route_map.connect('api', '/api/components', + controller=api, action='components') + route_map.connect('api', '/api/tenants', + controller=api, action='tenants') + route_map.connect('api', '/api/tenant/{tenant_name}/info', + controller=api, action='tenant_info') + route_map.connect('api', '/api/tenant/{tenant_name}/status', + controller=api, action='status') + route_map.connect('api', '/api/tenant/{tenant_name}/status/change' + '/{change}', + controller=api, action='status_change') + route_map.connect('api', '/api/tenant/{tenant_name}/semaphores', + controller=api, action='semaphores') + route_map.connect('api', '/api/tenant/{tenant_name}/jobs', + controller=api, action='jobs') + route_map.connect('api', '/api/tenant/{tenant_name}/job/{job_name}', + controller=api, action='job') + # if no auth configured, deactivate admin routes + if include_auth: + # route order is important, put project actions before the more + # generic tenant/{tenant_name}/project/{project} route + route_map.connect('api', + '/api/tenant/{tenant_name}/authorizations', + controller=api, + action='tenant_authorizations') + route_map.connect('api', + '/api/authorizations', + controller=api, + action='root_authorizations') + route_map.connect('api', '/api/tenant/{tenant_name}/promote', + controller=api, action='promote') + route_map.connect( + 'api', + '/api/tenant/{tenant_name}/project/{project_name:.*}/autohold', + controller=api, + conditions=dict(method=['GET', 'OPTIONS']), + action='autohold_project_get') + route_map.connect( + 'api', + '/api/tenant/{tenant_name}/project/{project_name:.*}/autohold', + controller=api, + conditions=dict(method=['POST']), + action='autohold_project_post') + route_map.connect( + 'api', + '/api/tenant/{tenant_name}/project/{project_name:.*}/enqueue', + controller=api, action='enqueue') + route_map.connect( + 'api', + '/api/tenant/{tenant_name}/project/{project_name:.*}/dequeue', + controller=api, action='dequeue') + route_map.connect('api', + '/api/tenant/{tenant_name}/autohold/{request_id}', + controller=api, + conditions=dict(method=['GET', 'OPTIONS']), + action='autohold_get') + route_map.connect('api', + '/api/tenant/{tenant_name}/autohold/{request_id}', + controller=api, + conditions=dict(method=['DELETE']), + action='autohold_delete') + route_map.connect('api', '/api/tenant/{tenant_name}/autohold', + controller=api, action='autohold_list') + route_map.connect('api', '/api/tenant/{tenant_name}/projects', + controller=api, action='projects') + route_map.connect('api', '/api/tenant/{tenant_name}/project/' + '{project_name:.*}', + controller=api, action='project') + route_map.connect( + 'api', + '/api/tenant/{tenant_name}/pipeline/{pipeline_name}' + '/project/{project_name:.*}/branch/{branch_name:.*}/freeze-jobs', + controller=api, action='project_freeze_jobs' + ) + route_map.connect( + 'api', + '/api/tenant/{tenant_name}/pipeline/{pipeline_name}' + '/project/{project_name:.*}/branch/{branch_name:.*}' + '/freeze-job/{job_name}', + controller=api, action='project_freeze_job' + ) + route_map.connect('api', '/api/tenant/{tenant_name}/pipelines', + controller=api, action='pipelines') + route_map.connect('api', '/api/tenant/{tenant_name}/labels', + controller=api, action='labels') + route_map.connect('api', '/api/tenant/{tenant_name}/nodes', + controller=api, action='nodes') + route_map.connect('api', '/api/tenant/{tenant_name}/key/' + '{project_name:.*}.pub', + controller=api, action='key') + route_map.connect('api', '/api/tenant/{tenant_name}/' + 'project-ssh-key/{project_name:.*}.pub', + controller=api, action='project_ssh_key') + route_map.connect('api', '/api/tenant/{tenant_name}/console-stream', + controller=api, action='console_stream_get', + conditions=dict(method=['GET'])) + route_map.connect('api', '/api/tenant/{tenant_name}/console-stream', + controller=api, action='console_stream_options', + conditions=dict(method=['OPTIONS'])) + route_map.connect('api', '/api/tenant/{tenant_name}/builds', + controller=api, action='builds') + route_map.connect('api', '/api/tenant/{tenant_name}/badge', + controller=api, action='badge') + route_map.connect('api', '/api/tenant/{tenant_name}/build/{uuid}', + controller=api, action='build') + route_map.connect('api', '/api/tenant/{tenant_name}/buildsets', + controller=api, action='buildsets') + route_map.connect('api', '/api/tenant/{tenant_name}/buildset/{uuid}', + controller=api, action='buildset') + route_map.connect('api', '/api/tenant/{tenant_name}/build-times', + controller=api, action='build_times') + route_map.connect('api', '/api/tenant/{tenant_name}/config-errors', + controller=api, action='config_errors') + route_map.connect('api', '/api/tenant/{tenant_name}/tenant-status', + controller=api, action='tenant_status') + return route_map + def __init__(self, config, connections, @@ -2130,130 +2542,15 @@ class ZuulWeb(object): self.finger_tls_verify_hostnames = get_default( self.config, 'fingergw', 'tls_verify_hostnames', default=True) - route_map = cherrypy.dispatch.RoutesDispatcher() api = ZuulWebAPI(self) self.api = api - route_map.connect('api', '/api', - controller=api, action='index') - route_map.connect('api', '/api/info', - controller=api, action='info') - route_map.connect('api', '/api/connections', - controller=api, action='connections') - route_map.connect('api', '/api/components', - controller=api, action='components') - route_map.connect('api', '/api/tenants', - controller=api, action='tenants') - route_map.connect('api', '/api/tenant/{tenant_name}/info', - controller=api, action='tenant_info') - route_map.connect('api', '/api/tenant/{tenant_name}/status', - controller=api, action='status') - route_map.connect('api', '/api/tenant/{tenant_name}/status/change' - '/{change}', - controller=api, action='status_change') - route_map.connect('api', '/api/tenant/{tenant_name}/semaphores', - controller=api, action='semaphores') - route_map.connect('api', '/api/tenant/{tenant_name}/jobs', - controller=api, action='jobs') - route_map.connect('api', '/api/tenant/{tenant_name}/job/{job_name}', - controller=api, action='job') - # if no auth configured, deactivate admin routes - if self.authenticators.authenticators: - # route order is important, put project actions before the more - # generic tenant/{tenant_name}/project/{project} route - route_map.connect('api', - '/api/tenant/{tenant_name}/authorizations', - controller=api, - action='tenant_authorizations') - route_map.connect('api', - '/api/authorizations', - controller=api, - action='root_authorizations') - route_map.connect('api', '/api/tenant/{tenant_name}/promote', - controller=api, action='promote') - route_map.connect( - 'api', - '/api/tenant/{tenant_name}/project/{project_name:.*}/autohold', - controller=api, - conditions=dict(method=['GET', 'OPTIONS']), - action='autohold_project_get') - route_map.connect( - 'api', - '/api/tenant/{tenant_name}/project/{project_name:.*}/autohold', - controller=api, - conditions=dict(method=['POST']), - action='autohold_project_post') - route_map.connect( - 'api', - '/api/tenant/{tenant_name}/project/{project_name:.*}/enqueue', - controller=api, action='enqueue') - route_map.connect( - 'api', - '/api/tenant/{tenant_name}/project/{project_name:.*}/dequeue', - controller=api, action='dequeue') - route_map.connect('api', - '/api/tenant/{tenant_name}/autohold/{request_id}', - controller=api, - conditions=dict(method=['GET', 'OPTIONS']), - action='autohold_get') - route_map.connect('api', - '/api/tenant/{tenant_name}/autohold/{request_id}', - controller=api, - conditions=dict(method=['DELETE']), - action='autohold_delete') - route_map.connect('api', '/api/tenant/{tenant_name}/autohold', - controller=api, action='autohold_list') - route_map.connect('api', '/api/tenant/{tenant_name}/projects', - controller=api, action='projects') - route_map.connect('api', '/api/tenant/{tenant_name}/project/' - '{project_name:.*}', - controller=api, action='project') + route_map = self.generateRouteMap( + api, bool(self.authenticators.authenticators)) + # Add fallthrough routes at the end for the static html/js files route_map.connect( - 'api', - '/api/tenant/{tenant_name}/pipeline/{pipeline_name}' - '/project/{project_name:.*}/branch/{branch_name:.*}/freeze-jobs', - controller=api, action='project_freeze_jobs' - ) - route_map.connect( - 'api', - '/api/tenant/{tenant_name}/pipeline/{pipeline_name}' - '/project/{project_name:.*}/branch/{branch_name:.*}' - '/freeze-job/{job_name}', - controller=api, action='project_freeze_job' - ) - route_map.connect('api', '/api/tenant/{tenant_name}/pipelines', - controller=api, action='pipelines') - route_map.connect('api', '/api/tenant/{tenant_name}/labels', - controller=api, action='labels') - route_map.connect('api', '/api/tenant/{tenant_name}/nodes', - controller=api, action='nodes') - route_map.connect('api', '/api/tenant/{tenant_name}/key/' - '{project_name:.*}.pub', - controller=api, action='key') - route_map.connect('api', '/api/tenant/{tenant_name}/' - 'project-ssh-key/{project_name:.*}.pub', - controller=api, action='project_ssh_key') - route_map.connect('api', '/api/tenant/{tenant_name}/console-stream', - controller=api, action='console_stream_get', - conditions=dict(method=['GET'])) - route_map.connect('api', '/api/tenant/{tenant_name}/console-stream', - controller=api, action='console_stream_options', - conditions=dict(method=['OPTIONS'])) - route_map.connect('api', '/api/tenant/{tenant_name}/builds', - controller=api, action='builds') - route_map.connect('api', '/api/tenant/{tenant_name}/badge', - controller=api, action='badge') - route_map.connect('api', '/api/tenant/{tenant_name}/build/{uuid}', - controller=api, action='build') - route_map.connect('api', '/api/tenant/{tenant_name}/buildsets', - controller=api, action='buildsets') - route_map.connect('api', '/api/tenant/{tenant_name}/buildset/{uuid}', - controller=api, action='buildset') - route_map.connect('api', '/api/tenant/{tenant_name}/build-times', - controller=api, action='build_times') - route_map.connect('api', '/api/tenant/{tenant_name}/config-errors', - controller=api, action='config_errors') - route_map.connect('api', '/api/tenant/{tenant_name}/tenant-status', - controller=api, action='tenant_status') + 'root_static', '/{path:.*}', + controller=StaticHandler(self.static_path), + action='default') for connection in connections.connections.values(): controller = connection.getWebController(self) @@ -2262,12 +2559,6 @@ class ZuulWeb(object): controller, '/api/connection/%s' % connection.connection_name) - # Add fallthrough routes at the end for the static html/js files - route_map.connect( - 'root_static', '/{path:.*}', - controller=StaticHandler(self.static_path), - action='default') - cherrypy.tools.stats = StatsTool(self.statsd, self.metrics) conf = {