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:
James E. Blair 2022-09-07 14:28:12 -07:00
parent 0b4c6b0117
commit 06cfe2cacd
5 changed files with 141 additions and 4 deletions

View File

@ -1377,6 +1377,69 @@ class TestWebMultiTenant(BaseTestWeb):
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):
tenant_config_file = 'config/empty-config/main.yaml'

View File

@ -1722,10 +1722,11 @@ class TenantParser(object):
tenant.layout = self._parseLayout(
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:
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
# change data in ZooKeeper. In case we are in a zuul-web context,
# we don't want to do that.

View File

@ -659,6 +659,13 @@ class PipelineState(zkobject.ZKObject):
safe_pipeline = urllib.parse.quote_plus(pipeline.name)
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):
return f'{self.getPath()}/dirty'
@ -3871,6 +3878,13 @@ class BuildSet(zkobject.ZKObject):
def getPath(self):
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):
data = {
# "item": self.item,
@ -4285,6 +4299,13 @@ class QueueItem(zkobject.ZKObject):
def itemPath(cls, pipeline_path, 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):
if isinstance(self.event, TriggerEvent):
event_type = "TriggerEvent"

View File

@ -47,6 +47,7 @@ from zuul.lib.monitoring import MonitoringServer
from zuul.lib.re2util import filter_allowed_disallowed
from zuul.model import (
Abide,
BuildSet,
Branch,
ChangeQueue,
DequeueEvent,
@ -796,6 +797,7 @@ class ZuulWebAPI(object):
'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',
@ -1533,6 +1535,44 @@ class ZuulWebAPI(object):
resp.headers['Access-Control-Allow-Origin'] = '*'
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.tools.save_params()
@cherrypy.tools.websocket(handler_cls=LogStreamHandler)
@ -1864,6 +1904,8 @@ class ZuulWeb(object):
controller=api, action='status')
route_map.connect('api', '/api/tenant/{tenant}/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}',

View File

@ -41,8 +41,12 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
semaphore_root = "/zuul/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)
if read_only:
statsd = None
self.read_only = read_only
self.abide = abide
self.layout = layout
self.statsd = statsd
@ -71,6 +75,8 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
self.log.exception("Unable to send semaphore stats:")
def acquire(self, item, job, request_resources):
if self.read_only:
raise RuntimeError("Read-only semaphore handler")
if not job.semaphores:
return True
@ -190,6 +196,8 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
break
def release(self, sched, item, job, quiet=False):
if self.read_only:
raise RuntimeError("Read-only semaphore handler")
if not job.semaphores:
return
@ -242,6 +250,8 @@ class SemaphoreHandler(ZooKeeperSimpleBase):
return 1 if semaphore is None else semaphore.max
def cleanupLeaks(self):
if self.read_only:
raise RuntimeError("Read-only semaphore handler")
# MODEL_API: >1
if COMPONENT_REGISTRY.model_api < 2:
self.log.warning("Skipping semaphore cleanup since minimum model "