2822 lines
107 KiB
Python
Executable File
2822 lines
107 KiB
Python
Executable File
# Copyright (c) 2017 Red Hat
|
|
# Copyright 2021-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 cherrypy
|
|
import socket
|
|
from collections import defaultdict
|
|
from contextlib import suppress
|
|
|
|
from opentelemetry import trace
|
|
from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
|
|
from ws4py.websocket import WebSocket
|
|
import codecs
|
|
import copy
|
|
from datetime import datetime
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import select
|
|
import ssl
|
|
import threading
|
|
import uuid
|
|
import prometheus_client
|
|
import urllib.parse
|
|
import types
|
|
|
|
import zuul.executor.common
|
|
from zuul import exceptions
|
|
from zuul.configloader import ConfigLoader
|
|
from zuul.connection import BaseConnection, ReadOnlyBranchCacheError
|
|
import zuul.lib.repl
|
|
from zuul.lib import commandsocket, encryption, streamer_utils, tracing
|
|
from zuul.lib.ansible import AnsibleManager
|
|
from zuul.lib.jsonutil import ZuulJSONEncoder
|
|
from zuul.lib.keystorage import KeyStorage
|
|
from zuul.lib.monitoring import MonitoringServer
|
|
from zuul.lib.re2util import filter_allowed_disallowed
|
|
from zuul import model
|
|
from zuul.model import (
|
|
Abide,
|
|
BuildSet,
|
|
Branch,
|
|
ChangeQueue,
|
|
DequeueEvent,
|
|
EnqueueEvent,
|
|
HoldRequest,
|
|
PromoteEvent,
|
|
QueueItem,
|
|
SystemAttributes,
|
|
UnparsedAbideConfig,
|
|
WebInfo,
|
|
)
|
|
from zuul.version import get_version_string
|
|
from zuul.zk import ZooKeeperClient
|
|
from zuul.zk.components import COMPONENT_REGISTRY, WebComponent
|
|
from zuul.zk.config_cache import SystemConfigCache, UnparsedConfigCache
|
|
from zuul.zk.event_queues import (
|
|
TenantManagementEventQueue,
|
|
TenantTriggerEventQueue,
|
|
PipelineManagementEventQueue,
|
|
PipelineResultEventQueue,
|
|
PipelineTriggerEventQueue,
|
|
)
|
|
from zuul.zk.executor import ExecutorApi
|
|
from zuul.zk.layout import LayoutStateStore
|
|
from zuul.zk.locks import tenant_read_lock
|
|
from zuul.zk.nodepool import ZooKeeperNodepool
|
|
from zuul.zk.system import ZuulSystem
|
|
from zuul.zk.zkobject import LocalZKContext, ZKContext
|
|
from zuul.lib.auth import AuthenticatorRegistry
|
|
from zuul.lib.config import get_default
|
|
from zuul.lib.logutil import get_annotated_logger
|
|
from zuul.lib.statsd import get_statsd, normalize_statsd_name
|
|
from zuul.web.logutil import ZuulCherrypyLogManager
|
|
|
|
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
|
|
cherrypy.tools.websocket = WebSocketTool()
|
|
|
|
COMMANDS = [
|
|
commandsocket.StopCommand,
|
|
commandsocket.ReplCommand,
|
|
commandsocket.NoReplCommand,
|
|
]
|
|
|
|
|
|
def get_zuul_request_id():
|
|
request = cherrypy.serving.request
|
|
if not hasattr(request, 'zuul_request_id'):
|
|
request.zuul_request_id = uuid.uuid4().hex
|
|
return request.zuul_request_id
|
|
|
|
|
|
def get_request_logger(logger=None):
|
|
if logger is None:
|
|
logger = logging.getLogger("zuul.web")
|
|
zuul_request_id = get_zuul_request_id()
|
|
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 BuildsetEventConverter:
|
|
# A class to encapsulate the conversion of database BuildsetEvent
|
|
# objects to API output.
|
|
def toDict(event):
|
|
event_time = _datetimeToString(event.event_time)
|
|
ret = {
|
|
'event_time': event_time,
|
|
'event_type': event.event_type,
|
|
'description': event.description,
|
|
}
|
|
return ret
|
|
|
|
def schema(builds=False):
|
|
ret = {
|
|
'event_time': str,
|
|
'event_type': str,
|
|
'description': str,
|
|
}
|
|
return Prop('The buildset event', ret)
|
|
|
|
|
|
class BuildsetConverter:
|
|
# A class to encapsulate the conversion of database Buildset
|
|
# objects to API output.
|
|
def toDict(buildset, builds=None, events=None):
|
|
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'] = [BuildConverter.toDict(b) for b in builds]
|
|
if events:
|
|
ret['events'] = [BuildsetEventConverter.toDict(e) for e in events]
|
|
return ret
|
|
|
|
def schema(builds=False, events=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()]
|
|
if events:
|
|
ret['events'] = [BuildsetEventConverter.schema()]
|
|
return Prop('The buildset', ret)
|
|
|
|
|
|
class APIError(cherrypy.HTTPError):
|
|
def __init__(self, code, json_doc=None, headers=None):
|
|
self._headers = headers or {}
|
|
self._json_doc = json_doc
|
|
super().__init__(code)
|
|
|
|
def set_response(self):
|
|
super().set_response()
|
|
resp = cherrypy.response
|
|
resp.headers.update(self._headers)
|
|
if self._json_doc:
|
|
ret = json.dumps(self._json_doc).encode('utf8')
|
|
resp.body = ret
|
|
resp.headers['Content-Type'] = 'application/json'
|
|
resp.headers["Content-Length"] = len(ret)
|
|
else:
|
|
resp.body = b''
|
|
resp.headers["Content-Length"] = '0'
|
|
|
|
|
|
class SaveParamsTool(cherrypy.Tool):
|
|
"""
|
|
Save the URL parameters to allow them to take precedence over query
|
|
string parameters.
|
|
"""
|
|
def __init__(self):
|
|
cherrypy.Tool.__init__(self, 'on_start_resource',
|
|
self.saveParams, priority=10)
|
|
|
|
def _setup(self):
|
|
cherrypy.Tool._setup(self)
|
|
cherrypy.request.hooks.attach('before_handler',
|
|
self.restoreParams)
|
|
|
|
def saveParams(self, restore=True):
|
|
cherrypy.request.url_params = cherrypy.request.params.copy()
|
|
cherrypy.request.url_params_restore = restore
|
|
|
|
def restoreParams(self):
|
|
if cherrypy.request.url_params_restore:
|
|
cherrypy.request.params.update(cherrypy.request.url_params)
|
|
|
|
|
|
cherrypy.tools.save_params = SaveParamsTool()
|
|
|
|
|
|
def handle_options(allowed_methods=None):
|
|
if cherrypy.request.method == 'OPTIONS':
|
|
methods = allowed_methods or ['GET', 'OPTIONS']
|
|
if allowed_methods and 'OPTIONS' not in allowed_methods:
|
|
methods = methods + ['OPTIONS']
|
|
# discard decorated handler
|
|
request = cherrypy.serving.request
|
|
request.handler = None
|
|
# Set CORS response headers
|
|
resp = cherrypy.response
|
|
resp.headers['Access-Control-Allow-Origin'] = '*'
|
|
resp.headers['Access-Control-Allow-Headers'] =\
|
|
', '.join(['Authorization', 'Content-Type'])
|
|
resp.headers['Access-Control-Allow-Methods'] =\
|
|
', '.join(methods)
|
|
# Allow caching of the preflight response
|
|
resp.headers['Access-Control-Max-Age'] = 86400
|
|
resp.status = 204
|
|
|
|
|
|
cherrypy.tools.handle_options = cherrypy.Tool('on_start_resource',
|
|
handle_options,
|
|
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
|
|
self.admin = admin
|
|
|
|
|
|
class AuthContext:
|
|
"""This stores common information about the authorization context for
|
|
a resource so that the request for the resource can be authorized
|
|
either in a Cherrpy tool or a Websocket handler.
|
|
|
|
:param Tenant tenant: The Zuul tenant object; supply if it is relevant
|
|
for authorization to the protected resource
|
|
:param bool require_admin: Whether admin access is required for
|
|
this resource
|
|
:param bool require_auth: Whether authenticated access is required for
|
|
this resource
|
|
"""
|
|
|
|
def __init__(self, tenant=None, require_admin=None, require_auth=None):
|
|
request = cherrypy.serving.request
|
|
zuulweb = request.app.root.zuulweb
|
|
if require_admin:
|
|
require_auth = True
|
|
|
|
if tenant:
|
|
if not require_auth and tenant.access_rules:
|
|
# This tenant requires auth for read-only access
|
|
require_auth = True
|
|
else:
|
|
if not require_auth and zuulweb.abide.api_root.access_rules:
|
|
# The API root requires auth for read-only access
|
|
require_auth = True
|
|
self.require_admin = require_admin
|
|
self.require_auth = require_auth
|
|
self.headers = request.headers
|
|
self.tenant = tenant
|
|
self.zuulweb = zuulweb
|
|
|
|
def validate(self, token=None):
|
|
"""Validate access to the resource
|
|
|
|
:param str token: The bearer token; if not supplied, it will be
|
|
retreieved from the request header
|
|
|
|
Raises an exception if authorization failed and was required.
|
|
|
|
:returns: an AuthInfo instance if authorization succeeded;
|
|
None if it failed but was not required.
|
|
|
|
"""
|
|
if token is None:
|
|
try:
|
|
token = self._getTokenFromHeader()
|
|
except APIError:
|
|
if self.require_auth:
|
|
raise
|
|
return None
|
|
try:
|
|
claims = self._getClaims(token)
|
|
except APIError:
|
|
if self.require_auth:
|
|
raise
|
|
return None
|
|
|
|
access, admin = self.zuulweb.api._isAuthorized(self.tenant, claims)
|
|
if ((self.require_auth and not access) or
|
|
(self.require_admin and not admin)):
|
|
raise APIError(403)
|
|
|
|
return AuthInfo(claims['__zuul_uid_claim'], admin)
|
|
|
|
def _getTokenFromHeader(self):
|
|
"""Make sure protected endpoints have a Authorization header with the
|
|
bearer token."""
|
|
token_header = self.headers.get('Authorization', None)
|
|
# Add basic checks here
|
|
if token_header is None:
|
|
e = 'MissingAuthError'
|
|
e_desc = 'Missing "Authorization" header'
|
|
elif not token_header.lower().startswith('bearer '):
|
|
e = 'InvalidAuthFormat'
|
|
e_desc = '"Authorization" header must start with "Bearer"'
|
|
else:
|
|
token = token_header[len('Bearer '):]
|
|
return token
|
|
error_header = '''Bearer realm="%s"
|
|
error="%s"
|
|
error_description="%s"''' % (self.zuulweb.authenticators.default_realm,
|
|
e,
|
|
e_desc)
|
|
error_data = {'description': e_desc,
|
|
'error': e,
|
|
'realm': self.zuulweb.authenticators.default_realm}
|
|
raise APIError(401, json_doc=error_data, headers={
|
|
"WWW-Authenticate": error_header
|
|
})
|
|
|
|
def _getClaims(self, token):
|
|
try:
|
|
claims = self.zuulweb.authenticators.authenticate(token)
|
|
except exceptions.AuthTokenException as e:
|
|
error_data = {'description': str(e.error_description),
|
|
'error': str(e.error),
|
|
'realm': str(e.realm)}
|
|
raise APIError(e.HTTPError, json_doc=error_data,
|
|
headers=e.getAdditionalHeaders())
|
|
return claims
|
|
|
|
|
|
def check_root_auth(**kw):
|
|
"""Use this for root-level (non-tenant) methods"""
|
|
cherrypy.response.headers['Access-Control-Allow-Origin'] = '*'
|
|
request = cherrypy.serving.request
|
|
if request.handler is None:
|
|
# handle_options has already aborted the request.
|
|
return
|
|
auth_context = AuthContext(**kw)
|
|
request.params['auth'] = auth_context.validate()
|
|
|
|
|
|
def check_tenant_auth(**kw):
|
|
"""Use this for tenant-scoped methods"""
|
|
cherrypy.response.headers['Access-Control-Allow-Origin'] = '*'
|
|
request = cherrypy.serving.request
|
|
zuulweb = request.app.root
|
|
if request.handler is None:
|
|
# handle_options has already aborted the request.
|
|
return
|
|
|
|
tenant_name = request.params.get('tenant_name')
|
|
# Always set the tenant variable
|
|
tenant = zuulweb._getTenantOrRaise(tenant_name)
|
|
request.params['tenant'] = tenant
|
|
auth_context = AuthContext(tenant=tenant, **kw)
|
|
request.params['auth'] = auth_context.validate()
|
|
|
|
|
|
cherrypy.tools.check_root_auth = cherrypy.Tool('on_start_resource',
|
|
check_root_auth,
|
|
priority=90)
|
|
cherrypy.tools.check_tenant_auth = cherrypy.Tool('on_start_resource',
|
|
check_tenant_auth,
|
|
priority=90)
|
|
|
|
|
|
class StatsTool(cherrypy.Tool):
|
|
def __init__(self, statsd, metrics):
|
|
self.statsd = statsd
|
|
self.metrics = metrics
|
|
self.hostname = normalize_statsd_name(socket.getfqdn())
|
|
cherrypy.Tool.__init__(self, 'on_start_resource',
|
|
self.emitStats)
|
|
|
|
def emitStats(self):
|
|
idle = cherrypy.server.httpserver.requests.idle
|
|
qsize = cherrypy.server.httpserver.requests.qsize
|
|
self.metrics.threadpool_idle.set(idle)
|
|
self.metrics.threadpool_queue.set(qsize)
|
|
if self.statsd:
|
|
self.statsd.gauge(
|
|
f'zuul.web.server.{self.hostname}.threadpool.idle',
|
|
idle)
|
|
self.statsd.gauge(
|
|
f'zuul.web.server.{self.hostname}.threadpool.queue',
|
|
qsize)
|
|
|
|
|
|
class WebMetrics:
|
|
def __init__(self):
|
|
self.threadpool_idle = prometheus_client.Gauge(
|
|
'web_threadpool_idle', 'The number of idle worker threads')
|
|
self.threadpool_queue = prometheus_client.Gauge(
|
|
'web_threadpool_queue', 'The number of queued requests')
|
|
self.streamers = prometheus_client.Gauge(
|
|
'web_streamers', 'The number of log streamers currently operating')
|
|
|
|
|
|
# Custom JSONEncoder that combines the ZuulJSONEncoder with cherrypy's
|
|
# JSON functionality.
|
|
class ZuulWebJSONEncoder(ZuulJSONEncoder):
|
|
|
|
def iterencode(self, value):
|
|
# Adapted from cherrypy/_json.py
|
|
for chunk in super().iterencode(value):
|
|
yield chunk.encode("utf-8")
|
|
|
|
|
|
json_encoder = ZuulWebJSONEncoder()
|
|
|
|
|
|
def json_handler(*args, **kwargs):
|
|
# Adapted from cherrypy/lib/jsontools.py
|
|
value = cherrypy.serving.request._json_inner_handler(*args, **kwargs)
|
|
return json_encoder.iterencode(value)
|
|
|
|
|
|
class RefFilter(object):
|
|
def __init__(self, desired):
|
|
self.desired = desired
|
|
|
|
def filterPayload(self, payload):
|
|
status = []
|
|
for pipeline in payload['pipelines']:
|
|
for change_queue in pipeline.get('change_queues', []):
|
|
for head in change_queue['heads']:
|
|
for item in head:
|
|
want_item = False
|
|
for ref in item['refs']:
|
|
if self.wantRef(ref):
|
|
want_item = True
|
|
break
|
|
if want_item:
|
|
status.append(copy.deepcopy(item))
|
|
return status
|
|
|
|
def wantRef(self, ref):
|
|
return ref['id'] == self.desired
|
|
|
|
|
|
class LogStreamHandler(WebSocket):
|
|
def __init__(self, *args, **kw):
|
|
kw['heartbeat_freq'] = 20
|
|
self.log = get_request_logger()
|
|
super(LogStreamHandler, self).__init__(*args, **kw)
|
|
self.streamer = None
|
|
|
|
# Because we lose our request context by the time we get the
|
|
# authorization token over the websocket protocol, we create
|
|
# an AuthContext here, and then perform delayed validation on
|
|
# messages we recieve.
|
|
request = cherrypy.serving.request
|
|
self.zuulweb = request.app.root.zuulweb
|
|
self.tenant_name = request.params.get('tenant_name')
|
|
tenant = self.zuulweb.api._getTenantOrRaise(self.tenant_name)
|
|
self.auth_context = AuthContext(tenant=tenant)
|
|
|
|
def received_message(self, message):
|
|
if message.is_text:
|
|
req = json.loads(message.data.decode('utf-8'))
|
|
self.log.debug("Websocket request: %s", req)
|
|
if self.streamer:
|
|
self.log.debug("Ignoring request due to existing streamer")
|
|
return
|
|
|
|
token = req.get('token')
|
|
try:
|
|
self.auth_context.validate(token=token)
|
|
except APIError as e:
|
|
if e._json_doc and e._json_doc.get('error'):
|
|
msg = e._json_doc.get('error').encode('utf8')[:123]
|
|
else:
|
|
msg = b'Authorization error'
|
|
return self.logClose(4000, msg)
|
|
|
|
try:
|
|
self._streamLog(req)
|
|
except Exception:
|
|
self.log.exception("Error processing websocket message:")
|
|
raise
|
|
|
|
def closed(self, code, reason=None):
|
|
self.log.debug("Websocket closed: %s %s", code, reason)
|
|
if self.streamer:
|
|
try:
|
|
self.streamer.zuulweb.stream_manager.unregisterStreamer(
|
|
self.streamer)
|
|
except Exception:
|
|
self.log.exception("Error on remote websocket close:")
|
|
|
|
def logClose(self, code, msg):
|
|
self.log.debug("Websocket close: %s %s", code, msg)
|
|
try:
|
|
self.close(code, msg)
|
|
except Exception:
|
|
self.log.exception("Error closing websocket:")
|
|
|
|
def _streamLog(self, request):
|
|
"""
|
|
Stream the log for the requested job back to the client.
|
|
|
|
:param dict request: The client request parameters.
|
|
"""
|
|
for key in ('uuid', 'logfile'):
|
|
if key not in request:
|
|
return self.logClose(
|
|
4000,
|
|
"'{key}' missing from request payload".format(
|
|
key=key))
|
|
|
|
try:
|
|
port_location = streamer_utils.getJobLogStreamAddress(
|
|
self.zuulweb.executor_api,
|
|
request['uuid'], source_zone=self.zuulweb.zone,
|
|
tenant_name=self.tenant_name)
|
|
except exceptions.StreamingError as e:
|
|
return self.logClose(4011, str(e))
|
|
|
|
if not port_location:
|
|
return self.logClose(4011, "Error with log streaming")
|
|
|
|
self.streamer = LogStreamer(
|
|
self.zuulweb, self,
|
|
port_location['server'], port_location['port'],
|
|
request['uuid'], port_location.get('use_ssl'))
|
|
|
|
|
|
class LogStreamer(object):
|
|
def __init__(self, zuulweb, websocket, server, port, build_uuid, use_ssl):
|
|
"""
|
|
Create a client to connect to the finger streamer and pull results.
|
|
|
|
:param str server: The executor server running the job.
|
|
:param str port: The executor server port.
|
|
:param str build_uuid: The build UUID to stream.
|
|
"""
|
|
self.fileno = None
|
|
self.log = websocket.log
|
|
self.log.debug("Connecting to finger server %s:%s", server, port)
|
|
Decoder = codecs.getincrementaldecoder('utf8')
|
|
self.decoder = Decoder()
|
|
self.zuulweb = zuulweb
|
|
self.finger_socket = socket.create_connection(
|
|
(server, port), timeout=10)
|
|
if use_ssl:
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
|
context.check_hostname = self.zuulweb.finger_tls_verify_hostnames
|
|
context.load_cert_chain(
|
|
self.zuulweb.finger_tls_cert, self.zuulweb.finger_tls_key)
|
|
context.load_verify_locations(self.zuulweb.finger_tls_ca)
|
|
self.finger_socket = context.wrap_socket(
|
|
self.finger_socket, server_hostname=server)
|
|
|
|
self.finger_socket.settimeout(None)
|
|
self.websocket = websocket
|
|
self.uuid = build_uuid
|
|
msg = "%s\n" % build_uuid # Must have a trailing newline!
|
|
self.finger_socket.sendall(msg.encode('utf-8'))
|
|
self.fileno = self.finger_socket.fileno()
|
|
self.zuulweb.stream_manager.registerStreamer(self)
|
|
|
|
def __repr__(self):
|
|
return '<LogStreamer %s uuid:%s fd:%s>' % (
|
|
self.websocket, self.uuid, self.fileno)
|
|
|
|
def errorClose(self):
|
|
try:
|
|
self.websocket.logClose(4011, "Unknown error")
|
|
except Exception:
|
|
self.log.exception("Error closing web:")
|
|
|
|
def closeSocket(self):
|
|
try:
|
|
self.finger_socket.close()
|
|
except Exception:
|
|
self.log.exception("Error closing streamer socket:")
|
|
|
|
def handle(self, event):
|
|
if event & select.POLLIN:
|
|
data = self.finger_socket.recv(1024)
|
|
if data:
|
|
data = self.decoder.decode(data)
|
|
if data:
|
|
self.websocket.send(data, False)
|
|
else:
|
|
# Make sure we flush anything left in the decoder
|
|
data = self.decoder.decode(b'', final=True)
|
|
if data:
|
|
self.websocket.send(data, False)
|
|
self.zuulweb.stream_manager.unregisterStreamer(self)
|
|
return self.websocket.logClose(1000, "No more data")
|
|
else:
|
|
self.zuulweb.stream_manager.unregisterStreamer(self)
|
|
return self.websocket.logClose(1000, "Remote error")
|
|
|
|
|
|
class ZuulWebAPI(object):
|
|
def __init__(self, zuulweb):
|
|
self.zuulweb = zuulweb
|
|
self.zk_client = zuulweb.zk_client
|
|
self.system = ZuulSystem(self.zk_client)
|
|
self.zk_nodepool = ZooKeeperNodepool(self.zk_client,
|
|
enable_node_cache=True)
|
|
self.status_caches = {}
|
|
self.status_cache_times = {}
|
|
self.status_cache_locks = defaultdict(threading.Lock)
|
|
self.tenants_cache = []
|
|
self.tenants_cache_time = 0
|
|
self.tenants_cache_lock = threading.Lock()
|
|
|
|
self.cache_expiry = 1
|
|
self.static_cache_expiry = zuulweb.static_cache_expiry
|
|
# SQL build query timeout, in milliseconds:
|
|
self.query_timeout = 30000
|
|
|
|
@property
|
|
def log(self):
|
|
return get_request_logger()
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options(allowed_methods=['POST', ])
|
|
@cherrypy.tools.check_tenant_auth(require_admin=True)
|
|
def dequeue(self, tenant_name, tenant, auth, project_name):
|
|
if cherrypy.request.method != 'POST':
|
|
raise cherrypy.HTTPError(405)
|
|
self.log.info(f'User {auth.uid} requesting dequeue on '
|
|
f'{tenant_name}/{project_name}')
|
|
|
|
project = self._getProjectOrRaise(tenant, project_name)
|
|
|
|
body = cherrypy.request.json
|
|
if 'pipeline' in body and (
|
|
('change' in body and 'ref' not in body) or
|
|
('change' not in body and 'ref' in body)):
|
|
# Validate the pipeline so we can enqueue the event directly
|
|
# in the pipeline management event queue and don't need to
|
|
# take the detour via the tenant management event queue.
|
|
pipeline_name = body['pipeline']
|
|
pipeline = tenant.layout.pipelines.get(pipeline_name)
|
|
if pipeline is None:
|
|
raise cherrypy.HTTPError(400, 'Unknown pipeline')
|
|
|
|
event = DequeueEvent(
|
|
tenant_name, pipeline_name, project.canonical_hostname,
|
|
project.name, body.get('change', None), body.get('ref', None))
|
|
event.zuul_event_id = get_zuul_request_id()
|
|
self.zuulweb.pipeline_management_events[tenant_name][
|
|
pipeline_name].put(event)
|
|
else:
|
|
raise cherrypy.HTTPError(400, 'Invalid request body')
|
|
return True
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options(allowed_methods=['POST', ])
|
|
@cherrypy.tools.check_tenant_auth(require_admin=True)
|
|
def enqueue(self, tenant_name, tenant, auth, project_name):
|
|
if cherrypy.request.method != 'POST':
|
|
raise cherrypy.HTTPError(405)
|
|
self.log.info(f'User {auth.uid} requesting enqueue on '
|
|
f'{tenant_name}/{project_name}')
|
|
|
|
project = self._getProjectOrRaise(tenant, project_name)
|
|
|
|
body = cherrypy.request.json
|
|
if 'pipeline' not in body:
|
|
raise cherrypy.HTTPError(400, 'Invalid request body')
|
|
|
|
# Validate the pipeline so we can enqueue the event directly
|
|
# in the pipeline management event queue and don't need to
|
|
# take the detour via the tenant management event queue.
|
|
pipeline_name = body['pipeline']
|
|
pipeline = tenant.layout.pipelines.get(pipeline_name)
|
|
if pipeline is None:
|
|
raise cherrypy.HTTPError(400, 'Unknown pipeline')
|
|
|
|
if 'change' in body:
|
|
return self._enqueue(tenant, project, pipeline, body['change'])
|
|
elif all(p in body for p in ['ref', 'oldrev', 'newrev']):
|
|
return self._enqueue_ref(tenant, project, pipeline, body['ref'],
|
|
body['oldrev'], body['newrev'])
|
|
else:
|
|
raise cherrypy.HTTPError(400, 'Invalid request body')
|
|
|
|
def _enqueue(self, tenant, project, pipeline, change):
|
|
event = EnqueueEvent(tenant.name, pipeline.name,
|
|
project.canonical_hostname, project.name,
|
|
change, ref=None, oldrev=None, newrev=None)
|
|
event.zuul_event_id = get_zuul_request_id()
|
|
self.zuulweb.pipeline_management_events[tenant.name][
|
|
pipeline.name].put(event)
|
|
|
|
return True
|
|
|
|
def _enqueue_ref(self, tenant, project, pipeline, ref, oldrev, newrev):
|
|
event = EnqueueEvent(tenant.name, pipeline.name,
|
|
project.canonical_hostname, project.name,
|
|
change=None, ref=ref, oldrev=oldrev,
|
|
newrev=newrev)
|
|
event.zuul_event_id = get_zuul_request_id()
|
|
self.zuulweb.pipeline_management_events[tenant.name][
|
|
pipeline.name].put(event)
|
|
|
|
return True
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options(allowed_methods=['POST', ])
|
|
@cherrypy.tools.check_tenant_auth(require_admin=True)
|
|
def promote(self, tenant_name, tenant, auth):
|
|
if cherrypy.request.method != 'POST':
|
|
raise cherrypy.HTTPError(405)
|
|
|
|
body = cherrypy.request.json
|
|
pipeline_name = body.get('pipeline')
|
|
changes = body.get('changes')
|
|
|
|
self.log.info(f'User {auth.uid} requesting promote on '
|
|
f'{tenant_name}/{pipeline_name}')
|
|
|
|
# Validate the pipeline so we can enqueue the event directly
|
|
# in the pipeline management event queue and don't need to
|
|
# take the detour via the tenant management event queue.
|
|
pipeline = tenant.layout.pipelines.get(pipeline_name)
|
|
if pipeline is None:
|
|
raise cherrypy.HTTPError(400, 'Unknown pipeline')
|
|
|
|
event = PromoteEvent(tenant_name, pipeline_name, changes)
|
|
event.zuul_event_id = get_zuul_request_id()
|
|
self.zuulweb.pipeline_management_events[tenant_name][
|
|
pipeline_name].put(event)
|
|
|
|
return True
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def autohold_list(self, tenant_name, tenant, auth, *args, **kwargs):
|
|
# filter by project if passed as a query string
|
|
project_name = cherrypy.request.params.get('project', None)
|
|
return self._autohold_list(tenant_name, project_name)
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options(allowed_methods=['GET', 'POST'])
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def autohold_project_get(self, tenant_name, tenant, auth, project_name):
|
|
# Note: GET handling is redundant with autohold_list
|
|
# and could be removed.
|
|
return self._autohold_list(tenant_name, project_name)
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
# Options handled by _get method
|
|
@cherrypy.tools.check_tenant_auth(require_admin=True)
|
|
def autohold_project_post(self, tenant_name, tenant, auth, project_name):
|
|
project = self._getProjectOrRaise(tenant, project_name)
|
|
self.log.info(f'User {auth.uid} requesting autohold on '
|
|
f'{tenant_name}/{project_name}')
|
|
|
|
jbody = cherrypy.request.json
|
|
|
|
# Validate the payload
|
|
jbody['change'] = jbody.get('change', None)
|
|
jbody['ref'] = jbody.get('ref', None)
|
|
count = jbody.get('count')
|
|
if jbody['change'] and jbody['ref']:
|
|
raise cherrypy.HTTPError(
|
|
400, 'change and ref are mutually exclusive')
|
|
if not all(p in jbody for p in [
|
|
'job', 'count', 'change', 'ref', 'reason',
|
|
'node_hold_expiration']):
|
|
raise cherrypy.HTTPError(400, 'Invalid request body')
|
|
if count < 0:
|
|
raise cherrypy.HTTPError(400, "Count must be greater 0")
|
|
|
|
project_name = project.canonical_name
|
|
|
|
if jbody['change']:
|
|
ref_filter = project.source.getRefForChange(jbody['change'])
|
|
elif jbody['ref']:
|
|
ref_filter = str(jbody['ref'])
|
|
else:
|
|
ref_filter = ".*"
|
|
|
|
self._autohold(tenant_name, project_name, jbody['job'], ref_filter,
|
|
jbody['reason'], jbody['count'],
|
|
jbody['node_hold_expiration'])
|
|
return True
|
|
|
|
def _autohold(self, tenant_name, project_name, job_name, ref_filter,
|
|
reason, count, node_hold_expiration):
|
|
key = (tenant_name, project_name, job_name, ref_filter)
|
|
self.log.debug("Autohold requested for %s", key)
|
|
|
|
request = HoldRequest()
|
|
request.tenant = tenant_name
|
|
request.project = project_name
|
|
request.job = job_name
|
|
request.ref_filter = ref_filter
|
|
request.reason = reason
|
|
request.max_count = count
|
|
|
|
zuul_globals = self.zuulweb.globals
|
|
# Set node_hold_expiration to default if no value is supplied
|
|
if node_hold_expiration is None:
|
|
node_hold_expiration = zuul_globals.default_hold_expiration
|
|
|
|
# Reset node_hold_expiration to max if it exceeds the max
|
|
elif zuul_globals.max_hold_expiration and (
|
|
node_hold_expiration == 0 or
|
|
node_hold_expiration > zuul_globals.max_hold_expiration):
|
|
node_hold_expiration = zuul_globals.max_hold_expiration
|
|
|
|
request.node_expiration = node_hold_expiration
|
|
|
|
# No need to lock it since we are creating a new one.
|
|
self.zk_nodepool.storeHoldRequest(request)
|
|
|
|
def _autohold_list(self, tenant_name, project_name=None):
|
|
result = []
|
|
for request_id in self.zk_nodepool.getHoldRequests():
|
|
request = self.zk_nodepool.getHoldRequest(request_id)
|
|
if not request:
|
|
continue
|
|
|
|
if tenant_name != request.tenant:
|
|
continue
|
|
|
|
if project_name is None or request.project.endswith(project_name):
|
|
result.append({
|
|
'id': request.id,
|
|
'tenant': request.tenant,
|
|
'project': request.project,
|
|
'job': request.job,
|
|
'ref_filter': request.ref_filter,
|
|
'max_count': request.max_count,
|
|
'current_count': request.current_count,
|
|
'reason': request.reason,
|
|
'node_expiration': request.node_expiration,
|
|
'expired': request.expired,
|
|
'nodes': request.nodes,
|
|
})
|
|
|
|
return result
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options(allowed_methods=['GET', 'DELETE', ])
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def autohold_get(self, tenant_name, tenant, auth, request_id):
|
|
request = self._getAutoholdRequest(tenant_name, request_id)
|
|
return {
|
|
'id': request.id,
|
|
'tenant': request.tenant,
|
|
'project': request.project,
|
|
'job': request.job,
|
|
'ref_filter': request.ref_filter,
|
|
'max_count': request.max_count,
|
|
'current_count': request.current_count,
|
|
'reason': request.reason,
|
|
'node_expiration': request.node_expiration,
|
|
'expired': request.expired,
|
|
'nodes': request.nodes,
|
|
}
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
# Options handled by get method
|
|
@cherrypy.tools.check_tenant_auth(require_admin=True)
|
|
def autohold_delete(self, tenant_name, tenant, auth, request_id):
|
|
request = self._getAutoholdRequest(tenant_name, request_id)
|
|
self.log.info(f'User {auth.uid} requesting autohold-delete on '
|
|
f'{request.tenant}/{request.project}')
|
|
|
|
# User is authorized, so remove the autohold request
|
|
self.log.debug("Removing autohold %s", request)
|
|
try:
|
|
self.zk_nodepool.deleteHoldRequest(request)
|
|
except Exception:
|
|
self.log.exception(
|
|
"Error removing autohold request %s:", request)
|
|
|
|
cherrypy.response.status = 204
|
|
|
|
def _getAutoholdRequest(self, tenant_name, request_id):
|
|
hold_request = None
|
|
try:
|
|
hold_request = self.zk_nodepool.getHoldRequest(request_id)
|
|
except Exception:
|
|
self.log.exception("Error retrieving autohold ID %s", request_id)
|
|
|
|
if hold_request is None:
|
|
raise cherrypy.HTTPError(
|
|
404, f'Hold request {request_id} not found.')
|
|
|
|
if tenant_name != hold_request.tenant:
|
|
# return 404 rather than 403 to avoid leaking tenant info
|
|
raise cherrypy.HTTPError(
|
|
404, 'Hold request {request_id} not found.')
|
|
|
|
return hold_request
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_root_auth()
|
|
def index(self, auth):
|
|
return {
|
|
'info': '/api/info',
|
|
'connections': '/api/connections',
|
|
'components': '/api/components',
|
|
'authorizations': '/api/authorizations',
|
|
'tenants': '/api/tenants',
|
|
'tenant_info': '/api/tenant/{tenant}/info',
|
|
'status': '/api/tenant/{tenant}/status',
|
|
'status_change': '/api/tenant/{tenant}/status/change/{change}',
|
|
'jobs': '/api/tenant/{tenant}/jobs',
|
|
'job': '/api/tenant/{tenant}/job/{job_name}',
|
|
'projects': '/api/tenant/{tenant}/projects',
|
|
'project': '/api/tenant/{tenant}/project/{project:.*}',
|
|
'project_freeze_jobs': '/api/tenant/{tenant}/pipeline/{pipeline}/'
|
|
'project/{project:.*}/branch/{branch:.*}/'
|
|
'freeze-jobs',
|
|
'pipelines': '/api/tenant/{tenant}/pipelines',
|
|
'semaphores': '/api/tenant/{tenant}/semaphores',
|
|
'labels': '/api/tenant/{tenant}/labels',
|
|
'nodes': '/api/tenant/{tenant}/nodes',
|
|
'key': '/api/tenant/{tenant}/key/{project:.*}.pub',
|
|
'project_ssh_key': '/api/tenant/{tenant}/project-ssh-key/'
|
|
'{project:.*}.pub',
|
|
'console_stream': '/api/tenant/{tenant}/console-stream',
|
|
'badge': '/api/tenant/{tenant}/badge',
|
|
'builds': '/api/tenant/{tenant}/builds',
|
|
'build': '/api/tenant/{tenant}/build/{uuid}',
|
|
'buildsets': '/api/tenant/{tenant}/buildsets',
|
|
'buildset': '/api/tenant/{tenant}/buildset/{uuid}',
|
|
'config_errors': '/api/tenant/{tenant}/config-errors',
|
|
'tenant_authorizations': ('/api/tenant/{tenant}'
|
|
'/authorizations'),
|
|
'tenant_status': '/api/tenant/{tenant}/tenant-status',
|
|
'autohold': '/api/tenant/{tenant}/project/{project:.*}/autohold',
|
|
'autohold_list': '/api/tenant/{tenant}/autohold',
|
|
'autohold_by_request_id': ('/api/tenant/{tenant}'
|
|
'/autohold/{request_id}'),
|
|
'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
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
# Info endpoints never require authentication because they supply
|
|
# authentication information.
|
|
def info(self):
|
|
info = self.zuulweb.info.copy()
|
|
auth_info = info.capabilities.capabilities['auth']
|
|
|
|
root_realm = self.zuulweb.abide.api_root.default_auth_realm
|
|
if root_realm:
|
|
auth_info['default_realm'] = root_realm
|
|
read_protected = bool(self.zuulweb.abide.api_root.access_rules)
|
|
auth_info['read_protected'] = read_protected
|
|
return self._handleInfo(info)
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
# Info endpoints never require authentication because they supply
|
|
# authentication information.
|
|
def tenant_info(self, tenant_name):
|
|
info = self.zuulweb.info.copy()
|
|
auth_info = info.capabilities.capabilities['auth']
|
|
info.tenant = tenant_name
|
|
tenant = self.zuulweb.abide.tenants.get(tenant_name)
|
|
if tenant is not None:
|
|
# TODO: should we return 404 if tenant not found?
|
|
if tenant.default_auth_realm is not None:
|
|
auth_info['default_realm'] = tenant.default_auth_realm
|
|
read_protected = bool(tenant.access_rules)
|
|
auth_info['read_protected'] = read_protected
|
|
return self._handleInfo(info)
|
|
|
|
def _handleInfo(self, info):
|
|
ret = {'info': info.toDict()}
|
|
resp = cherrypy.response
|
|
if self.static_cache_expiry:
|
|
resp.headers['Cache-Control'] = "public, max-age=%d" % \
|
|
self.static_cache_expiry
|
|
resp.last_modified = self.zuulweb.start_time
|
|
# We don't wrap info methods with check_auth
|
|
resp.headers['Access-Control-Allow-Origin'] = '*'
|
|
return ret
|
|
|
|
def _isAuthorized(self, tenant, claims):
|
|
# First, check for zuul.admin override
|
|
if tenant:
|
|
tenant_name = tenant.name
|
|
admin_rules = tenant.admin_rules
|
|
access_rules = tenant.access_rules
|
|
else:
|
|
tenant_name = '*'
|
|
admin_rules = []
|
|
access_rules = self.zuulweb.abide.api_root.access_rules
|
|
override = claims.get('zuul', {}).get('admin', [])
|
|
if (override == '*' or
|
|
(isinstance(override, list) and tenant_name in override)):
|
|
return (True, True)
|
|
|
|
if not tenant:
|
|
tenant_name = '<root>'
|
|
|
|
if access_rules:
|
|
access = False
|
|
else:
|
|
access = True
|
|
for rule_name in access_rules:
|
|
rule = self.zuulweb.abide.authz_rules.get(rule_name)
|
|
if not rule:
|
|
self.log.error('Undefined rule "%s"', rule_name)
|
|
continue
|
|
self.log.debug('Applying access rule "%s" from '
|
|
'tenant "%s" to claims %s',
|
|
rule_name, tenant_name, json.dumps(claims))
|
|
authorized = rule(claims, tenant)
|
|
if authorized:
|
|
if '__zuul_uid_claim' in claims:
|
|
uid = claims['__zuul_uid_claim']
|
|
else:
|
|
uid = json.dumps(claims)
|
|
self.log.info('%s authorized access on '
|
|
'tenant "%s" by rule "%s"',
|
|
uid, tenant_name, rule_name)
|
|
access = True
|
|
break
|
|
|
|
admin = False
|
|
for rule_name in admin_rules:
|
|
rule = self.zuulweb.abide.authz_rules.get(rule_name)
|
|
if not rule:
|
|
self.log.error('Undefined rule "%s"', rule_name)
|
|
continue
|
|
self.log.debug('Applying admin rule "%s" from '
|
|
'tenant "%s" to claims %s',
|
|
rule_name, tenant_name, json.dumps(claims))
|
|
authorized = rule(claims, tenant)
|
|
if authorized:
|
|
if '__zuul_uid_claim' in claims:
|
|
uid = claims['__zuul_uid_claim']
|
|
else:
|
|
uid = json.dumps(claims)
|
|
self.log.info('%s authorized admin on '
|
|
'tenant "%s" by rule "%s"',
|
|
uid, tenant_name, rule_name)
|
|
access = admin = True
|
|
break
|
|
return (access, admin)
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_root_auth(require_auth=True)
|
|
def root_authorizations(self, auth):
|
|
return {'zuul': {'admin': auth.admin,
|
|
'scope': ['*']}, }
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth(require_auth=True)
|
|
def tenant_authorizations(self, tenant_name, tenant, auth):
|
|
return {'zuul': {'admin': auth.admin,
|
|
'scope': [tenant_name, ]}, }
|
|
|
|
def _tenants(self):
|
|
result = []
|
|
with self.zuulweb.zk_context as ctx:
|
|
for tenant_name, tenant in sorted(
|
|
self.zuulweb.abide.tenants.items()):
|
|
queue_size = 0
|
|
for pipeline in tenant.layout.pipelines.values():
|
|
status = pipeline.summary.refresh(ctx)
|
|
for queue in status.get("change_queues", []):
|
|
for head in queue["heads"]:
|
|
for item in head:
|
|
if item["live"]:
|
|
queue_size += 1
|
|
|
|
result.append({
|
|
'name': tenant_name,
|
|
'projects': len(tenant.untrusted_projects),
|
|
'queue': queue_size,
|
|
})
|
|
return result
|
|
|
|
@cherrypy.expose
|
|
@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:
|
|
with self.tenants_cache_lock:
|
|
self.tenants_cache = self._tenants()
|
|
self.tenants_cache_time = time.time()
|
|
|
|
resp = cherrypy.response
|
|
resp.headers["Cache-Control"] = f"public, max-age={self.cache_expiry}"
|
|
last_modified = datetime.utcfromtimestamp(
|
|
self.tenants_cache_time
|
|
)
|
|
last_modified_header = last_modified.strftime('%a, %d %b %Y %X GMT')
|
|
resp.headers["Last-modified"] = last_modified_header
|
|
return self.tenants_cache
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_root_auth()
|
|
def connections(self, auth):
|
|
ret = [s.connection.toDict()
|
|
for s in self.zuulweb.connections.getSources()]
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out(content_type="application/json; charset=utf-8")
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_root_auth()
|
|
def components(self, auth):
|
|
ret = {}
|
|
for kind, components in self.zuulweb.component_registry.all():
|
|
for comp in components:
|
|
comp_json = {
|
|
"hostname": comp.hostname,
|
|
"state": comp.state,
|
|
"version": comp.version,
|
|
}
|
|
ret.setdefault(kind, []).append(comp_json)
|
|
return ret
|
|
|
|
def _getStatus(self, tenant):
|
|
cache_time = self.status_cache_times.get(tenant.name, 0)
|
|
if tenant.name not in self.status_cache_locks or \
|
|
(time.time() - cache_time) > self.cache_expiry:
|
|
if self.status_cache_locks[tenant.name].acquire(
|
|
blocking=False
|
|
):
|
|
try:
|
|
self.status_caches[tenant.name] =\
|
|
self.formatStatus(tenant)
|
|
self.status_cache_times[tenant.name] =\
|
|
time.time()
|
|
finally:
|
|
self.status_cache_locks[tenant.name].release()
|
|
if not self.status_caches.get(tenant.name):
|
|
# If the cache is empty at this point it means that we didn't
|
|
# get the lock but another thread is initializing the cache
|
|
# for the first time. In this case we just wait for the lock
|
|
# to wait for it to finish.
|
|
with self.status_cache_locks[tenant.name]:
|
|
pass
|
|
payload = self.status_caches[tenant.name]
|
|
resp = cherrypy.response
|
|
resp.headers["Cache-Control"] = f"public, max-age={self.cache_expiry}"
|
|
last_modified = datetime.utcfromtimestamp(
|
|
self.status_cache_times[tenant.name]
|
|
)
|
|
last_modified_header = last_modified.strftime('%a, %d %b %Y %X GMT')
|
|
resp.headers["Last-modified"] = last_modified_header
|
|
resp.headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
return payload
|
|
|
|
def formatStatus(self, tenant):
|
|
data = {}
|
|
data['zuul_version'] = self.zuulweb.component_info.version
|
|
|
|
data['trigger_event_queue'] = {}
|
|
data['trigger_event_queue']['length'] = len(
|
|
self.zuulweb.trigger_events[tenant.name])
|
|
data['management_event_queue'] = {}
|
|
data['management_event_queue']['length'] = len(
|
|
self.zuulweb.management_events[tenant.name]
|
|
)
|
|
data['connection_event_queues'] = {}
|
|
for connection in self.zuulweb.connections.connections.values():
|
|
queue = connection.getEventQueue()
|
|
if queue is not None:
|
|
data['connection_event_queues'][connection.connection_name] = {
|
|
'length': len(queue),
|
|
}
|
|
|
|
layout_state = self.zuulweb.tenant_layout_state[tenant.name]
|
|
data['last_reconfigured'] = layout_state.last_reconfigured * 1000
|
|
|
|
pipelines = []
|
|
data['pipelines'] = pipelines
|
|
|
|
trigger_event_queues = self.zuulweb.pipeline_trigger_events[
|
|
tenant.name]
|
|
result_event_queues = self.zuulweb.pipeline_result_events[tenant.name]
|
|
management_event_queues = self.zuulweb.pipeline_management_events[
|
|
tenant.name]
|
|
with self.zuulweb.zk_context as ctx:
|
|
for pipeline in tenant.layout.pipelines.values():
|
|
status = pipeline.summary.refresh(ctx)
|
|
status['trigger_events'] = len(
|
|
trigger_event_queues[pipeline.name])
|
|
status['result_events'] = len(
|
|
result_event_queues[pipeline.name])
|
|
status['management_events'] = len(
|
|
management_event_queues[pipeline.name])
|
|
pipelines.append(status)
|
|
return data, json.dumps(data).encode('utf-8')
|
|
|
|
def _getTenantOrRaise(self, tenant_name):
|
|
tenant = self.zuulweb.abide.tenants.get(tenant_name)
|
|
if tenant:
|
|
return tenant
|
|
if tenant_name not in self.zuulweb.unparsed_abide.tenants:
|
|
raise cherrypy.HTTPError(404, "Unknown tenant")
|
|
self.log.warning("Tenant %s isn't loaded", tenant_name)
|
|
raise cherrypy.HTTPError(422, f"Tenant {tenant_name} isn't ready")
|
|
|
|
def _getProjectOrRaise(self, tenant, project_name):
|
|
_, project = tenant.getProject(project_name)
|
|
if not project:
|
|
raise cherrypy.HTTPError(404, "Unknown project")
|
|
return project
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@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
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@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)
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(
|
|
content_type='application/json; charset=utf-8', handler=json_handler,
|
|
)
|
|
@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):
|
|
desc = None
|
|
tags = set()
|
|
variants = []
|
|
for variant in tenant.layout.jobs[job_name]:
|
|
if not desc and variant.description:
|
|
desc = variant.description.split('\n')[0]
|
|
if variant.tags:
|
|
tags.update(list(variant.tags))
|
|
job_variant = {}
|
|
if not variant.isBase():
|
|
if variant.parent:
|
|
job_variant['parent'] = str(variant.parent)
|
|
else:
|
|
job_variant['parent'] = tenant.default_base_job
|
|
branches = variant.getBranches()
|
|
if branches:
|
|
job_variant['branches'] = branches
|
|
if job_variant:
|
|
variants.append(job_variant)
|
|
|
|
job_output = {"name": job_name}
|
|
if desc:
|
|
job_output["description"] = desc
|
|
if variants:
|
|
job_output["variants"] = variants
|
|
if tags:
|
|
job_output["tags"] = list(tags)
|
|
result.append(job_output)
|
|
|
|
return result
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def config_errors(self, tenant_name, tenant, auth,
|
|
project=None, branch=None, severity=None, name=None,
|
|
limit=50, skip=0):
|
|
skip = int(skip)
|
|
limit = int(limit)
|
|
count = 0
|
|
ret = []
|
|
for e in tenant.layout.loading_errors.errors:
|
|
if not (
|
|
(project is None or e.key.context.project_name == project) and
|
|
(branch is None or e.key.context.branch == branch) and
|
|
(severity is None or e.severity == severity) and
|
|
(name is None or e.name == name)):
|
|
continue
|
|
count += 1
|
|
if count <= skip:
|
|
continue
|
|
ret.append({
|
|
'source_context': e.key.context.toDict(),
|
|
'error': e.error,
|
|
'short_error': e.short_error,
|
|
'severity': e.severity,
|
|
'name': e.name,
|
|
})
|
|
if len(ret) >= limit:
|
|
break
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def tenant_status(self, tenant_name, tenant, auth):
|
|
ret = {
|
|
'config_error_count': len(tenant.layout.loading_errors.errors),
|
|
}
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(
|
|
content_type='application/json; charset=utf-8', handler=json_handler)
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def job(self, tenant_name, tenant, auth, job_name):
|
|
job_name = urllib.parse.unquote_plus(job_name)
|
|
job_variants = tenant.layout.jobs.get(job_name)
|
|
result = []
|
|
for job in job_variants:
|
|
result.append(job.toDict(tenant))
|
|
|
|
return result
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def projects(self, tenant_name, tenant, auth):
|
|
result = []
|
|
for project in tenant.config_projects:
|
|
pobj = project.toDict()
|
|
pobj['type'] = "config"
|
|
result.append(pobj)
|
|
for project in tenant.untrusted_projects:
|
|
pobj = project.toDict()
|
|
pobj['type'] = "untrusted"
|
|
result.append(pobj)
|
|
|
|
return sorted(result, key=lambda project: project["name"])
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(
|
|
content_type='application/json; charset=utf-8', handler=json_handler)
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def project(self, tenant_name, tenant, auth, project_name):
|
|
project = self._getProjectOrRaise(tenant, project_name)
|
|
|
|
result = project.toDict()
|
|
result['configs'] = []
|
|
md = tenant.layout.getProjectMetadata(project.canonical_name).toDict()
|
|
md['merge_mode'] = model.get_merge_mode_name(md['merge_mode'])
|
|
result['metadata'] = md
|
|
configs = tenant.layout.getAllProjectConfigs(project.canonical_name)
|
|
for config_obj in configs:
|
|
config = config_obj.toDict()
|
|
config['pipelines'] = []
|
|
for pipeline_name, pipeline_config in sorted(
|
|
config_obj.pipelines.items()):
|
|
pipeline = pipeline_config.toDict()
|
|
pipeline['name'] = pipeline_name
|
|
pipeline['jobs'] = []
|
|
for jobs in pipeline_config.job_list.jobs.values():
|
|
job_list = []
|
|
for job in jobs:
|
|
job_list.append(job.toDict(tenant))
|
|
pipeline['jobs'].append(job_list)
|
|
config['pipelines'].append(pipeline)
|
|
result['configs'].append(config)
|
|
|
|
return result
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def pipelines(self, tenant_name, tenant, auth):
|
|
ret = []
|
|
for pipeline, pipeline_config in tenant.layout.pipelines.items():
|
|
triggers = []
|
|
for trigger in pipeline_config.triggers:
|
|
if isinstance(trigger.connection, BaseConnection):
|
|
name = trigger.connection.connection_name
|
|
else:
|
|
# Trigger not based on a connection doesn't use this attr
|
|
name = trigger.name
|
|
triggers.append({
|
|
"name": name,
|
|
"driver": trigger.driver.name,
|
|
})
|
|
ret.append({"name": pipeline, "triggers": triggers})
|
|
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def labels(self, tenant_name, tenant, auth):
|
|
allowed_labels = tenant.allowed_labels or []
|
|
disallowed_labels = tenant.disallowed_labels or []
|
|
labels = set()
|
|
for launcher in self.zk_nodepool.getRegisteredLaunchers():
|
|
labels.update(filter_allowed_disallowed(
|
|
launcher.supported_labels,
|
|
allowed_labels, disallowed_labels))
|
|
ret = [{'name': label} for label in sorted(labels)]
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def nodes(self, tenant_name, tenant, auth):
|
|
ret = []
|
|
for node_id in self.zk_nodepool.getNodes(cached=True):
|
|
node = self.zk_nodepool.getNode(node_id)
|
|
# This returns all nodes; some of which may not be
|
|
# intended for use by Zuul, so be extra careful checking
|
|
# user_data.
|
|
if not (node.user_data and
|
|
isinstance(node.user_data, dict) and
|
|
node.user_data.get('zuul_system') ==
|
|
self.system.system_id and
|
|
node.tenant_name == tenant_name):
|
|
continue
|
|
node_data = {}
|
|
for key in ("id", "type", "connection_type", "external_id",
|
|
"provider", "state", "state_time", "comment"):
|
|
node_data[key] = getattr(node, key, None)
|
|
ret.append(node_data)
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@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)
|
|
|
|
key = encryption.serialize_rsa_public_key(project.public_secrets_key)
|
|
resp = cherrypy.response
|
|
resp.headers['Content-Type'] = 'text/plain'
|
|
return key
|
|
|
|
@cherrypy.expose
|
|
@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)
|
|
|
|
key = f"{project.public_ssh_key}\n"
|
|
resp = cherrypy.response
|
|
resp.headers['Content-Type'] = 'text/plain'
|
|
return key
|
|
|
|
def _get_connection(self):
|
|
return self.zuulweb.connections.connections['database']
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@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():
|
|
raise cherrypy.HTTPError(
|
|
404,
|
|
f'Tenant {tenant_name} does not exist.')
|
|
|
|
# If final is None, we return all builds, both final and non-final
|
|
if final is not None:
|
|
final = final.lower() == "true"
|
|
|
|
if complete is not None:
|
|
complete = complete.lower() == 'true'
|
|
|
|
try:
|
|
_idx_max = idx_max is not None and int(idx_max) or idx_max
|
|
_idx_min = idx_min is not None and int(idx_min) or idx_min
|
|
except ValueError:
|
|
raise cherrypy.HTTPError(400, 'idx_min, idx_max must be integers')
|
|
|
|
builds = connection.getBuilds(
|
|
tenant=tenant_name, project=project, pipeline=pipeline,
|
|
change=change, branch=branch, patchset=patchset, ref=ref,
|
|
newrev=newrev, uuid=uuid, job_name=job_name, voting=voting,
|
|
nodeset=nodeset, result=result, final=final, held=held,
|
|
complete=complete, limit=limit, offset=skip, idx_min=_idx_min,
|
|
idx_max=_idx_max, exclude_result=exclude_result,
|
|
query_timeout=self.query_timeout)
|
|
|
|
return [BuildConverter.toDict(b, b.buildset, skip_refs=True)
|
|
for b in builds]
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def build(self, tenant_name, tenant, auth, uuid):
|
|
connection = self._get_connection()
|
|
|
|
data = connection.getBuild(tenant_name, uuid)
|
|
if not data:
|
|
raise cherrypy.HTTPError(404, "Build not found")
|
|
data = BuildConverter.toDict(data, data.buildset)
|
|
return data
|
|
|
|
def buildTimeToDict(self, build):
|
|
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,
|
|
'start_time': start_time,
|
|
'end_time': end_time,
|
|
'duration': duration,
|
|
'final': build.final,
|
|
'project': build.ref.project,
|
|
'branch': build.ref.branch,
|
|
'pipeline': build.buildset.pipeline,
|
|
'ref': build.ref.ref,
|
|
}
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def build_times(self, tenant_name, tenant, auth, project=None,
|
|
pipeline=None, branch=None,
|
|
ref=None, job_name=None,
|
|
final=None,
|
|
start_time=None, end_time=None,
|
|
limit=50, skip=0,
|
|
exclude_result=None):
|
|
connection = self._get_connection()
|
|
|
|
if tenant_name not in self.zuulweb.abide.tenants.keys():
|
|
raise cherrypy.HTTPError(
|
|
404,
|
|
f'Tenant {tenant_name} does not exist.')
|
|
|
|
# If final is None, we return all builds, both final and non-final
|
|
if final is not None:
|
|
final = final.lower() == "true"
|
|
|
|
build_times = connection.getBuildTimes(
|
|
tenant=tenant_name, project=project, pipeline=pipeline,
|
|
branch=branch, ref=ref,
|
|
job_name=job_name,
|
|
final=final,
|
|
limit=limit, offset=skip,
|
|
exclude_result=exclude_result,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
query_timeout=self.query_timeout)
|
|
|
|
return [self.buildTimeToDict(b) for b in build_times]
|
|
|
|
@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(
|
|
tenant=tenant_name, project=project, pipeline=pipeline,
|
|
branch=branch, complete=True, limit=1,
|
|
query_timeout=self.query_timeout)
|
|
if not buildsets:
|
|
raise cherrypy.HTTPError(404, 'No buildset found')
|
|
|
|
if buildsets[0].result == 'SUCCESS':
|
|
file = 'passing.svg'
|
|
else:
|
|
file = 'failing.svg'
|
|
path = os.path.join(self.zuulweb.static_path, file)
|
|
|
|
# Ensure the badge are not cached
|
|
cherrypy.response.headers['Cache-Control'] = "no-cache"
|
|
|
|
return cherrypy.lib.static.serve_file(
|
|
path=path, content_type="image/svg+xml")
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@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,
|
|
result=None, complete=None, limit=50, skip=0,
|
|
idx_min=None, idx_max=None):
|
|
connection = self._get_connection()
|
|
|
|
if complete:
|
|
complete = complete.lower() == 'true'
|
|
|
|
try:
|
|
_idx_max = idx_max is not None and int(idx_max) or idx_max
|
|
_idx_min = idx_min is not None and int(idx_min) or idx_min
|
|
except ValueError:
|
|
raise cherrypy.HTTPError(400, 'idx_min, idx_max must be integers')
|
|
|
|
buildsets = connection.getBuildsets(
|
|
tenant=tenant_name, project=project, pipeline=pipeline,
|
|
change=change, branch=branch, patchset=patchset, ref=ref,
|
|
newrev=newrev, uuid=uuid, result=result, complete=complete,
|
|
limit=limit, offset=skip, idx_min=_idx_min, idx_max=_idx_max,
|
|
query_timeout=self.query_timeout)
|
|
|
|
return [BuildsetConverter.toDict(b) for b in buildsets]
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@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 a buildset',
|
|
schema=BuildsetConverter.schema(builds=True, events=True),
|
|
)
|
|
@openapi_response(404, 'Tenant not found')
|
|
def buildset(self, tenant_name, tenant, auth, uuid):
|
|
connection = self._get_connection()
|
|
|
|
data = connection.getBuildset(tenant_name, uuid)
|
|
if not data:
|
|
raise cherrypy.HTTPError(404, "Buildset not found")
|
|
data = BuildsetConverter.toDict(data, data.builds,
|
|
data.buildset_events)
|
|
return data
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(
|
|
content_type='application/json; charset=utf-8', handler=json_handler,
|
|
)
|
|
@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())
|
|
names = names.union(tenant.global_semaphores)
|
|
for semaphore_name in sorted(names):
|
|
semaphore = tenant.layout.getSemaphore(
|
|
self.zuulweb.abide, semaphore_name)
|
|
holders = tenant.semaphore_handler.semaphoreHolders(semaphore_name)
|
|
this_tenant = []
|
|
other_tenants = 0
|
|
for holder in holders:
|
|
(holder_tenant, holder_pipeline,
|
|
holder_item_uuid, holder_buildset_uuid
|
|
) = BuildSet.parsePath(holder['buildset_path'])
|
|
if holder_tenant != tenant_name:
|
|
other_tenants += 1
|
|
continue
|
|
this_tenant.append({'buildset_uuid': holder_buildset_uuid,
|
|
'job_name': holder['job_name']})
|
|
sem_out = {'name': semaphore.name,
|
|
'global': semaphore.global_scope,
|
|
'max': semaphore.max,
|
|
'holders': {
|
|
'count': len(this_tenant) + other_tenants,
|
|
'this_tenant': this_tenant,
|
|
'other_tenants': other_tenants},
|
|
}
|
|
result.append(sem_out)
|
|
return result
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.handle_options()
|
|
# We don't check auth here since we would never fall through to it
|
|
def console_stream_options(self, tenant_name):
|
|
pass
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.websocket(handler_cls=LogStreamHandler)
|
|
# Options handling in _options method
|
|
# The Authorization header is not included when upgrading to
|
|
# websocket, so the websocket handler itself will validate auth
|
|
# using the websocket protocol.
|
|
def console_stream_get(self, tenant_name):
|
|
pass
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def project_freeze_jobs(self, tenant_name, tenant, auth,
|
|
pipeline_name, project_name, branch_name):
|
|
item, change = self._freeze_jobs(
|
|
tenant, pipeline_name, project_name, branch_name)
|
|
|
|
output = []
|
|
for job in item.current_build_set.job_graph.getJobs():
|
|
output.append({
|
|
'name': job.name,
|
|
'dependencies':
|
|
list(map(lambda x: x.toDict(), job.dependencies)),
|
|
})
|
|
|
|
ret = output
|
|
return ret
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.save_params()
|
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
|
@cherrypy.tools.handle_options()
|
|
@cherrypy.tools.check_tenant_auth()
|
|
def project_freeze_job(self, tenant_name, tenant, auth,
|
|
pipeline_name, project_name, branch_name,
|
|
job_name):
|
|
# TODO(jhesketh): Allow a canonical change/item to be passed in which
|
|
# would return the job with any in-change modifications.
|
|
item, change = self._freeze_jobs(
|
|
tenant, pipeline_name, project_name, branch_name)
|
|
job = item.current_build_set.job_graph.getJob(
|
|
job_name, change.cache_key)
|
|
if not job:
|
|
raise cherrypy.HTTPError(404)
|
|
|
|
uuid = "0" * 32
|
|
params = zuul.executor.common.construct_build_params(
|
|
uuid, self.zuulweb.connections, job, item, item.pipeline)
|
|
params['zuul'].update(zuul.executor.common.zuul_params_from_job(job))
|
|
del params['job_ref']
|
|
del params['parent_data']
|
|
del params['secret_parent_data']
|
|
params['job'] = job.name
|
|
params['zuul']['buildset'] = None
|
|
params['timeout'] = job.timeout
|
|
params['post_timeout'] = job.post_timeout
|
|
params['override_branch'] = job.override_branch
|
|
params['override_checkout'] = job.override_checkout
|
|
params['ansible_version'] = job.ansible_version
|
|
params['ansible_split_streams'] = job.ansible_split_streams
|
|
params['workspace_scheme'] = job.workspace_scheme
|
|
if job.name != 'noop':
|
|
params['playbooks'] = job.run
|
|
params['pre_playbooks'] = job.pre_run
|
|
params['post_playbooks'] = job.post_run
|
|
params['cleanup_playbooks'] = job.cleanup_run
|
|
params["nodeset"] = job.nodeset.toDict()
|
|
params['vars'] = job.variables
|
|
params['extra_vars'] = job.extra_variables
|
|
params['host_vars'] = job.host_variables
|
|
params['group_vars'] = job.group_variables
|
|
params['secret_vars'] = job.secret_parent_data
|
|
params['failure_output'] = job.failure_output
|
|
|
|
ret = params
|
|
return ret
|
|
|
|
def _freeze_jobs(self, tenant, pipeline_name, project_name,
|
|
branch_name):
|
|
|
|
project = self._getProjectOrRaise(tenant, project_name)
|
|
pipeline = tenant.layout.pipelines.get(pipeline_name)
|
|
if not pipeline:
|
|
raise cherrypy.HTTPError(404, 'Unknown pipeline')
|
|
|
|
change = Branch(project)
|
|
change.branch = branch_name or "master"
|
|
change.cache_stat = FakeCacheKey()
|
|
with LocalZKContext(self.log) as context:
|
|
queue = ChangeQueue.new(context, pipeline=pipeline)
|
|
item = QueueItem.new(context, queue=queue, changes=[change])
|
|
item.freezeJobGraph(tenant.layout, context,
|
|
skip_file_matcher=True,
|
|
redact_secrets_and_keys=True)
|
|
|
|
return item, change
|
|
|
|
|
|
class StaticHandler(object):
|
|
def __init__(self, root):
|
|
self.root = root
|
|
|
|
def default(self, path, **kwargs):
|
|
# Try to handle static file first
|
|
handled = cherrypy.lib.static.staticdir(
|
|
section="",
|
|
dir=self.root,
|
|
index='index.html')
|
|
if not path or not handled:
|
|
# When not found, serve the index.html
|
|
return cherrypy.lib.static.serve_file(
|
|
path=os.path.join(self.root, "index.html"),
|
|
content_type="text/html")
|
|
else:
|
|
return cherrypy.lib.static.serve_file(
|
|
path=os.path.join(self.root, path))
|
|
|
|
|
|
class StreamManager(object):
|
|
log = logging.getLogger("zuul.web")
|
|
|
|
def __init__(self, statsd, metrics):
|
|
self.thread = None
|
|
self.statsd = statsd
|
|
self.metrics = metrics
|
|
self.hostname = normalize_statsd_name(socket.getfqdn())
|
|
self.streamers = {}
|
|
self.poll = select.poll()
|
|
self.bitmask = (select.POLLIN | select.POLLERR |
|
|
select.POLLHUP | select.POLLNVAL)
|
|
self.wake_read, self.wake_write = os.pipe()
|
|
self.poll.register(self.wake_read, self.bitmask)
|
|
self.poll_lock = threading.Lock()
|
|
|
|
def start(self):
|
|
self._stopped = False
|
|
self.thread = threading.Thread(
|
|
target=self.run,
|
|
name='StreamManager')
|
|
self.thread.start()
|
|
|
|
def stop(self):
|
|
if self.thread:
|
|
self._stopped = True
|
|
os.write(self.wake_write, b'\n')
|
|
self.thread.join()
|
|
|
|
def run(self):
|
|
while not self._stopped:
|
|
try:
|
|
self._run()
|
|
except Exception:
|
|
self.log.exception("Error in StreamManager run method")
|
|
|
|
def _run(self):
|
|
for fd, event in self.poll.poll():
|
|
if self._stopped:
|
|
return
|
|
if fd == self.wake_read:
|
|
os.read(self.wake_read, 1024)
|
|
continue
|
|
streamer = self.streamers.get(fd)
|
|
if streamer:
|
|
try:
|
|
streamer.handle(event)
|
|
except Exception:
|
|
self.log.exception("Error in streamer:")
|
|
streamer.errorClose()
|
|
self.unregisterStreamer(streamer)
|
|
else:
|
|
with self.poll_lock:
|
|
# Double check this now that we have the lock
|
|
streamer = self.streamers.get(fd)
|
|
if not streamer:
|
|
self.log.error(
|
|
"Unregistering missing streamer fd: %s", fd)
|
|
try:
|
|
self.poll.unregister(fd)
|
|
except KeyError:
|
|
pass
|
|
|
|
def emitStats(self):
|
|
streamers = len(self.streamers)
|
|
self.metrics.streamers.set(streamers)
|
|
if self.statsd:
|
|
self.statsd.gauge(f'zuul.web.server.{self.hostname}.streamers',
|
|
streamers)
|
|
|
|
def registerStreamer(self, streamer):
|
|
with self.poll_lock:
|
|
self.log.debug("Registering streamer %s", streamer)
|
|
self.streamers[streamer.fileno] = streamer
|
|
self.poll.register(streamer.fileno, self.bitmask)
|
|
os.write(self.wake_write, b'\n')
|
|
self.emitStats()
|
|
|
|
def unregisterStreamer(self, streamer):
|
|
with self.poll_lock:
|
|
self.log.debug("Unregistering streamer %s", streamer)
|
|
old_streamer = self.streamers.get(streamer.fileno)
|
|
if old_streamer and old_streamer is streamer:
|
|
# Otherwise, we may have a new streamer which reused
|
|
# the fileno, so leave the poll registration in place.
|
|
del self.streamers[streamer.fileno]
|
|
try:
|
|
self.poll.unregister(streamer.fileno)
|
|
except KeyError:
|
|
pass
|
|
except Exception:
|
|
self.log.exception("Error unregistering streamer:")
|
|
streamer.closeSocket()
|
|
self.emitStats()
|
|
|
|
|
|
class FakeCacheKey:
|
|
class Dummy():
|
|
pass
|
|
|
|
def __init__(self):
|
|
self.key = self.Dummy()
|
|
self.key.reference = uuid.uuid4().hex
|
|
|
|
|
|
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',
|
|
|