Add semaphores to REST API
This adds information about semaphores to the REST API. It allows for inspection of the known semaphores in a tenant, the current number of jobs holding the semaphore, and information about each holder iff that holder is in the current tenant. Followup changes will add zuul-client and zuul-web support for the API, along with docs and release notes. Change-Id: I6ff57ca8db11add2429eefcc8b560abc9c074f4a
This commit is contained in:
parent
0b4c6b0117
commit
06cfe2cacd
|
@ -1377,6 +1377,69 @@ class TestWebMultiTenant(BaseTestWeb):
|
||||||
sorted(["tenant-one", "tenant-two", "tenant-four"]))
|
sorted(["tenant-one", "tenant-two", "tenant-four"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebGlobalSemaphores(BaseTestWeb):
|
||||||
|
tenant_config_file = 'config/global-semaphores-config/main.yaml'
|
||||||
|
|
||||||
|
def test_web_semaphores(self):
|
||||||
|
self.executor_server.hold_jobs_in_build = True
|
||||||
|
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
|
||||||
|
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
|
||||||
|
self.waitUntilSettled()
|
||||||
|
|
||||||
|
B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
|
||||||
|
self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
|
||||||
|
self.waitUntilSettled()
|
||||||
|
|
||||||
|
self.assertBuilds([
|
||||||
|
dict(name='test-global-semaphore', changes='1,1'),
|
||||||
|
dict(name='test-common-semaphore', changes='1,1'),
|
||||||
|
dict(name='test-project1-semaphore', changes='1,1'),
|
||||||
|
dict(name='test-global-semaphore', changes='2,1'),
|
||||||
|
dict(name='test-common-semaphore', changes='2,1'),
|
||||||
|
dict(name='test-project2-semaphore', changes='2,1'),
|
||||||
|
])
|
||||||
|
|
||||||
|
tenant1_buildset_uuid = self.builds[0].parameters['zuul']['buildset']
|
||||||
|
data = self.get_url('api/tenant/tenant-one/semaphores').json()
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
{'name': 'common-semaphore',
|
||||||
|
'global': False,
|
||||||
|
'max': 10,
|
||||||
|
'holders': {
|
||||||
|
'count': 1,
|
||||||
|
'this_tenant': [
|
||||||
|
{'buildset_uuid': tenant1_buildset_uuid,
|
||||||
|
'job_name': 'test-common-semaphore'}
|
||||||
|
],
|
||||||
|
'other_tenants': 0
|
||||||
|
}},
|
||||||
|
{'name': 'global-semaphore',
|
||||||
|
'global': True,
|
||||||
|
'max': 100,
|
||||||
|
'holders': {
|
||||||
|
'count': 2,
|
||||||
|
'this_tenant': [
|
||||||
|
{'buildset_uuid': tenant1_buildset_uuid,
|
||||||
|
'job_name': 'test-global-semaphore'}
|
||||||
|
],
|
||||||
|
'other_tenants': 1
|
||||||
|
}},
|
||||||
|
{'name': 'project1-semaphore',
|
||||||
|
'global': False,
|
||||||
|
'max': 11,
|
||||||
|
'holders': {
|
||||||
|
'count': 1,
|
||||||
|
'this_tenant': [
|
||||||
|
{'buildset_uuid': tenant1_buildset_uuid,
|
||||||
|
'job_name': 'test-project1-semaphore'}
|
||||||
|
],
|
||||||
|
'other_tenants': 0
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
self.assertEqual(expected, data)
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyConfig(BaseTestWeb):
|
class TestEmptyConfig(BaseTestWeb):
|
||||||
tenant_config_file = 'config/empty-config/main.yaml'
|
tenant_config_file = 'config/empty-config/main.yaml'
|
||||||
|
|
||||||
|
|
|
@ -1722,10 +1722,11 @@ class TenantParser(object):
|
||||||
tenant.layout = self._parseLayout(
|
tenant.layout = self._parseLayout(
|
||||||
tenant, parsed_config, loading_errors, layout_uuid)
|
tenant, parsed_config, loading_errors, layout_uuid)
|
||||||
|
|
||||||
|
tenant.semaphore_handler = SemaphoreHandler(
|
||||||
|
self.zk_client, self.statsd, tenant.name, tenant.layout, abide,
|
||||||
|
read_only=(not bool(self.scheduler))
|
||||||
|
)
|
||||||
if self.scheduler:
|
if self.scheduler:
|
||||||
tenant.semaphore_handler = SemaphoreHandler(
|
|
||||||
self.zk_client, self.statsd, tenant.name, tenant.layout, abide
|
|
||||||
)
|
|
||||||
# Only call the postConfig hook if we have a scheduler as this will
|
# Only call the postConfig hook if we have a scheduler as this will
|
||||||
# change data in ZooKeeper. In case we are in a zuul-web context,
|
# change data in ZooKeeper. In case we are in a zuul-web context,
|
||||||
# we don't want to do that.
|
# we don't want to do that.
|
||||||
|
|
|
@ -659,6 +659,13 @@ class PipelineState(zkobject.ZKObject):
|
||||||
safe_pipeline = urllib.parse.quote_plus(pipeline.name)
|
safe_pipeline = urllib.parse.quote_plus(pipeline.name)
|
||||||
return f"/zuul/tenant/{safe_tenant}/pipeline/{safe_pipeline}"
|
return f"/zuul/tenant/{safe_tenant}/pipeline/{safe_pipeline}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parsePath(self, path):
|
||||||
|
"""Return path components for use by the REST API"""
|
||||||
|
root, safe_tenant, pipeline, safe_pipeline = path.rsplit('/', 3)
|
||||||
|
return (urllib.parse.unquote_plus(safe_tenant),
|
||||||
|
urllib.parse.unquote_plus(safe_pipeline))
|
||||||
|
|
||||||
def _dirtyPath(self):
|
def _dirtyPath(self):
|
||||||
return f'{self.getPath()}/dirty'
|
return f'{self.getPath()}/dirty'
|
||||||
|
|
||||||
|
@ -3871,6 +3878,13 @@ class BuildSet(zkobject.ZKObject):
|
||||||
def getPath(self):
|
def getPath(self):
|
||||||
return f"{self.item.getPath()}/buildset/{self.uuid}"
|
return f"{self.item.getPath()}/buildset/{self.uuid}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parsePath(self, path):
|
||||||
|
"""Return path components for use by the REST API"""
|
||||||
|
item_path, bs, uuid = path.rsplit('/', 2)
|
||||||
|
tenant, pipeline, item_uuid = QueueItem.parsePath(item_path)
|
||||||
|
return (tenant, pipeline, item_uuid, uuid)
|
||||||
|
|
||||||
def serialize(self, context):
|
def serialize(self, context):
|
||||||
data = {
|
data = {
|
||||||
# "item": self.item,
|
# "item": self.item,
|
||||||
|
@ -4285,6 +4299,13 @@ class QueueItem(zkobject.ZKObject):
|
||||||
def itemPath(cls, pipeline_path, item_uuid):
|
def itemPath(cls, pipeline_path, item_uuid):
|
||||||
return f"{pipeline_path}/item/{item_uuid}"
|
return f"{pipeline_path}/item/{item_uuid}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parsePath(self, path):
|
||||||
|
"""Return path components for use by the REST API"""
|
||||||
|
pipeline_path, item, uuid = path.rsplit('/', 2)
|
||||||
|
tenant, pipeline = PipelineState.parsePath(pipeline_path)
|
||||||
|
return (tenant, pipeline, uuid)
|
||||||
|
|
||||||
def serialize(self, context):
|
def serialize(self, context):
|
||||||
if isinstance(self.event, TriggerEvent):
|
if isinstance(self.event, TriggerEvent):
|
||||||
event_type = "TriggerEvent"
|
event_type = "TriggerEvent"
|
||||||
|
|
|
@ -47,6 +47,7 @@ from zuul.lib.monitoring import MonitoringServer
|
||||||
from zuul.lib.re2util import filter_allowed_disallowed
|
from zuul.lib.re2util import filter_allowed_disallowed
|
||||||
from zuul.model import (
|
from zuul.model import (
|
||||||
Abide,
|
Abide,
|
||||||
|
BuildSet,
|
||||||
Branch,
|
Branch,
|
||||||
ChangeQueue,
|
ChangeQueue,
|
||||||
DequeueEvent,
|
DequeueEvent,
|
||||||
|
@ -796,6 +797,7 @@ class ZuulWebAPI(object):
|
||||||
'project/{project:.*}/branch/{branch:.*}/'
|
'project/{project:.*}/branch/{branch:.*}/'
|
||||||
'freeze-jobs',
|
'freeze-jobs',
|
||||||
'pipelines': '/api/tenant/{tenant}/pipelines',
|
'pipelines': '/api/tenant/{tenant}/pipelines',
|
||||||
|
'semaphores': '/api/tenant/{tenant}/semaphores',
|
||||||
'labels': '/api/tenant/{tenant}/labels',
|
'labels': '/api/tenant/{tenant}/labels',
|
||||||
'nodes': '/api/tenant/{tenant}/nodes',
|
'nodes': '/api/tenant/{tenant}/nodes',
|
||||||
'key': '/api/tenant/{tenant}/key/{project:.*}.pub',
|
'key': '/api/tenant/{tenant}/key/{project:.*}.pub',
|
||||||
|
@ -1533,6 +1535,44 @@ class ZuulWebAPI(object):
|
||||||
resp.headers['Access-Control-Allow-Origin'] = '*'
|
resp.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.save_params()
|
||||||
|
@cherrypy.tools.json_out(
|
||||||
|
content_type='application/json; charset=utf-8', handler=json_handler,
|
||||||
|
)
|
||||||
|
def semaphores(self, tenant_name):
|
||||||
|
tenant = self._getTenantOrRaise(tenant_name)
|
||||||
|
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)
|
||||||
|
resp = cherrypy.response
|
||||||
|
resp.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
return result
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@cherrypy.tools.save_params()
|
@cherrypy.tools.save_params()
|
||||||
@cherrypy.tools.websocket(handler_cls=LogStreamHandler)
|
@cherrypy.tools.websocket(handler_cls=LogStreamHandler)
|
||||||
|
@ -1864,6 +1904,8 @@ class ZuulWeb(object):
|
||||||
controller=api, action='status')
|
controller=api, action='status')
|
||||||
route_map.connect('api', '/api/tenant/{tenant}/status/change/{change}',
|
route_map.connect('api', '/api/tenant/{tenant}/status/change/{change}',
|
||||||
controller=api, action='status_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',
|
route_map.connect('api', '/api/tenant/{tenant_name}/jobs',
|
||||||
controller=api, action='jobs')
|
controller=api, action='jobs')
|
||||||
route_map.connect('api', '/api/tenant/{tenant_name}/job/{job_name}',
|
route_map.connect('api', '/api/tenant/{tenant_name}/job/{job_name}',
|
||||||
|
|
|
@ -41,8 +41,12 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
|
||||||
semaphore_root = "/zuul/semaphores"
|
semaphore_root = "/zuul/semaphores"
|
||||||
global_semaphore_root = "/zuul/global-semaphores"
|
global_semaphore_root = "/zuul/global-semaphores"
|
||||||
|
|
||||||
def __init__(self, client, statsd, tenant_name, layout, abide):
|
def __init__(self, client, statsd, tenant_name, layout, abide,
|
||||||
|
read_only=False):
|
||||||
super().__init__(client)
|
super().__init__(client)
|
||||||
|
if read_only:
|
||||||
|
statsd = None
|
||||||
|
self.read_only = read_only
|
||||||
self.abide = abide
|
self.abide = abide
|
||||||
self.layout = layout
|
self.layout = layout
|
||||||
self.statsd = statsd
|
self.statsd = statsd
|
||||||
|
@ -71,6 +75,8 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
|
||||||
self.log.exception("Unable to send semaphore stats:")
|
self.log.exception("Unable to send semaphore stats:")
|
||||||
|
|
||||||
def acquire(self, item, job, request_resources):
|
def acquire(self, item, job, request_resources):
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("Read-only semaphore handler")
|
||||||
if not job.semaphores:
|
if not job.semaphores:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -190,6 +196,8 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
|
||||||
break
|
break
|
||||||
|
|
||||||
def release(self, sched, item, job, quiet=False):
|
def release(self, sched, item, job, quiet=False):
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("Read-only semaphore handler")
|
||||||
if not job.semaphores:
|
if not job.semaphores:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -242,6 +250,8 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
|
||||||
return 1 if semaphore is None else semaphore.max
|
return 1 if semaphore is None else semaphore.max
|
||||||
|
|
||||||
def cleanupLeaks(self):
|
def cleanupLeaks(self):
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("Read-only semaphore handler")
|
||||||
# MODEL_API: >1
|
# MODEL_API: >1
|
||||||
if COMPONENT_REGISTRY.model_api < 2:
|
if COMPONENT_REGISTRY.model_api < 2:
|
||||||
self.log.warning("Skipping semaphore cleanup since minimum model "
|
self.log.warning("Skipping semaphore cleanup since minimum model "
|
||||||
|
|
Loading…
Reference in New Issue