cyborg/cyborg/api/controllers/v2/arqs.py

349 lines
14 KiB
Python

# Copyright 2019 Intel, Inc.
# All Rights Reserved.
#
# 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 pecan
from six.moves import http_client
import wsme
from wsme import types as wtypes
from oslo_log import log
from cyborg.api.controllers import base
from cyborg.api.controllers import link
from cyborg.api.controllers import types
from cyborg.api.controllers.v2 import utils
from cyborg.api.controllers.v2 import versions
from cyborg.api import expose
from cyborg.common import constants
from cyborg.common import exception
from cyborg.common.i18n import _
from cyborg.common import policy
from cyborg import objects
LOG = log.getLogger(__name__)
class ARQ(base.APIBase):
"""API representation of an ARQ.
This class enforces type checking and value constraints, and converts
between the internal object model and the API representation.
"""
uuid = types.uuid
"""The UUID of the device profile"""
state = wtypes.text # obvious meanings
device_profile_name = wtypes.text
device_profile_group_id = wtypes.IntegerType()
hostname = wtypes.text
"""The host name to which the ARQ is bound, if any"""
device_rp_uuid = wtypes.text
"""The UUID of the bound device RP, if any"""
instance_uuid = wtypes.text
"""The UUID of the instance associated with this ARQ, if any"""
project_id = wtypes.text
"""The UUID of the instance project_id associated with this ARQ, if any"""
attach_handle_type = wtypes.text
attach_handle_info = {wtypes.text: wtypes.text}
links = wsme.wsattr([link.Link], readonly=True)
"""A list containing a self link"""
def __init__(self, **kwargs):
super(ARQ, self).__init__(**kwargs)
self.fields = []
for field in objects.ARQ.fields:
self.fields.append(field)
setattr(self, field, kwargs.get(field, wtypes.Unset))
@classmethod
def convert_with_links(cls, obj_arq):
api_arq = cls(**obj_arq.as_dict())
api_arq.links = [
link.Link.make_link('self', pecan.request.public_url,
'accelerator_requests', api_arq.uuid)
]
return api_arq
class ARQCollection(base.APIBase):
"""API representation of a collection of arqs."""
arqs = [ARQ]
"""A list containing arq objects"""
@classmethod
def convert_with_links(cls, obj_arqs):
collection = cls()
collection.arqs = [ARQ.convert_with_links(obj_arq)
for obj_arq in obj_arqs]
return collection
class ARQsController(base.CyborgController):
"""REST controller for ARQs.
For the relationship betweens ARQs and device profiles, see
nova/nova/accelerator/cyborg.py.
"""
def _get_devprof(self, context, devprof_name):
"""Get the contents of a device profile.
Since this is just a read, it is ok for the API layer
to do this, instead of the conductor.
"""
try:
obj_devprof = objects.DeviceProfile.get_by_name(context,
devprof_name)
return obj_devprof
except Exception:
return None
@policy.authorize_wsgi("cyborg:arq", "create", False)
@expose.expose(ARQCollection, body=types.jsontype,
status_code=http_client.CREATED)
def post(self, req):
"""Create one or more ARQs for a single device profile.
Request body:
{ 'device_profile_name': <string> }
Future:
{ 'device_profile_name': <string> # required
'device_profile_group_id': <integer>, # opt, default=0
'image_uuid': <glance-image-UUID>, #optional, for future
}
:param req: request body.
"""
LOG.info("[arq] post req = (%s)", req)
context = pecan.request.context
devprof = None
dp_name = req.get('device_profile_name')
if dp_name is not None:
devprof = self._get_devprof(context, dp_name)
if devprof is None:
raise exception.ResourceNotFound(
resource='Device Profile',
msg='with name=%s' % dp_name)
else:
raise exception.DeviceProfileNameNeeded()
LOG.info('[arqs] post. device profile name=%s', dp_name)
extarq_list = []
for group_id, group in enumerate(devprof.groups):
accel_resources = [
int(val) for key, val in group.items()
if key.startswith('resources')]
# If/when we introduce non-accelerator resources, like
# device-local memory, the key search above needs to be
# made specific to accelerator resources only.
num_accels = sum(accel_resources)
arq_fields = {
'device_profile_name': devprof.name,
'device_profile_group_id': group_id,
}
for i in range(num_accels):
obj_arq = objects.ARQ(context, **arq_fields)
extarq_fields = {'arq': obj_arq}
obj_extarq = objects.ExtARQ(context, **extarq_fields)
# TODO(Sundar) The conductor must do all db writes
new_extarq = obj_extarq.create(context, devprof.id)
extarq_list.append(new_extarq)
ret = ARQCollection.convert_with_links(
[extarq.arq for extarq in extarq_list])
LOG.info('[arqs] post returned: %s', ret)
return ret
@policy.authorize_wsgi("cyborg:arq", "get_one")
@expose.expose(ARQ, wtypes.text)
def get_one(self, uuid):
"""Get a single ARQ by UUID."""
context = pecan.request.context
extarq = objects.ExtARQ.get(context, uuid)
return ARQ.convert_with_links(extarq.arq)
@policy.authorize_wsgi("cyborg:arq", "get_all", False)
@expose.expose(ARQCollection, wtypes.text, types.uuid)
def get_all(self, bind_state=None, instance=None):
"""Retrieve a list of arqs."""
# TODO(Sundar) Need to implement 'arq=uuid1,...' query parameter
LOG.info('[arqs] get_all. bind_state:(%s), instance:(%s)',
bind_state or '', instance or '')
context = pecan.request.context
extarqs = objects.ExtARQ.list(context)
state_map = constants.ARQ_BIND_STATES_STATUS_MAP
valid_bind_states = list(state_map.keys())
arqs = [extarq.arq for extarq in extarqs]
# TODO(Sundar): Optimize by doing the filtering in the db layer
# Apply instance filter before state filter.
if bind_state:
if bind_state != 'resolved':
raise exception.ARQInvalidState(state=bind_state)
if instance:
new_arqs = [arq for arq in arqs
if arq['instance_uuid'] == instance]
arqs = new_arqs
if bind_state:
for arq in new_arqs:
if arq['state'] not in valid_bind_states:
# NOTE(Sundar) This should return HTTP code 423
# if any ARQ for this instance is not resolved.
LOG.warning('Some of ARQs for instance %s is not '
'resolved', instance)
return wsme.api.Response(
None,
status_code=http_client.LOCKED)
elif bind_state:
arqs = [arq for arq in arqs
if arq['state'] in valid_bind_states]
ret = ARQCollection.convert_with_links(arqs)
LOG.info('[arqs:get_all] Returned: %s', ret)
return ret
@policy.authorize_wsgi("cyborg:arq", "delete", False)
@expose.expose(None, wtypes.text, wtypes.text,
status_code=http_client.NO_CONTENT)
def delete(self, arqs=None, instance=None):
"""Delete one or more ARQS.
The request can be either one of these two forms:
DELETE /v2/accelerator_requests?arqs=uuid1,uuid2,...
DELETE /v2/accelerator_requests?instance=uuid
The second form is idempotent, i.e., it would have the same effect
if called repeatedly with the same instance UUID. In other words,
it would not raise an error on the second and later attempts even if
the first one has deleted the ARQs. Whereas the first form is not
idempotent: if one or more of the ARQs do not exist, it would raise
an error. Nova uses the second form: so repeated calls do not cause
issues.
:param arq: List of ARQ UUIDs
:param instance: UUID of instance whose ARQs need to be deleted
"""
context = pecan.request.context
if (arqs and instance) or ((not arqs) and (not instance)):
raise exception.ObjectActionError(
action='delete',
reason='Provide either an ARQ uuid list or an instance UUID')
elif arqs:
LOG.info("[arqs] delete. arqs=(%s)", arqs)
arqlist = arqs.split(',')
objects.ExtARQ.delete_by_uuid(context, arqlist)
else: # instance is not None
LOG.info("[arqs] delete. instance=(%s)", instance)
objects.ExtARQ.delete_by_instance(context, instance)
def _validate_arq_patch(self, patch):
"""Validate a single patch for an ARQ.
:param patch: a JSON PATCH document.
The patch must be of the form [{..}], as specified in the
value field of arq_uuid in patch() method below.
:returns: dict of valid fields
"""
valid_fields = {'hostname': None,
'device_rp_uuid': None,
'instance_uuid': None}
if utils.allow_project_id():
valid_fields['project_id'] = None
if ((not all(p['op'] == 'add' for p in patch)) and
(not all(p['op'] == 'remove' for p in patch))):
raise exception.PatchError(
reason='Every op must be add or remove')
for p in patch:
path = p['path'].lstrip('/')
if path == 'project_id' and not utils.allow_project_id():
raise exception.NotAcceptable(_(
"Request not acceptable. The minimal required API "
"version should be %(base)s.%(opr)s") %
{'base': versions.BASE_VERSION,
'opr': versions.MINOR_1_PROJECT_ID})
if path not in valid_fields.keys():
reason = 'Invalid path in patch {}'.format(p['path'])
raise exception.PatchError(reason=reason)
if p['op'] == 'add':
valid_fields[path] = p['value']
not_found = [field for field, value in valid_fields.items()
if value is None]
if patch[0]['op'] == 'add' and len(not_found) > 0:
msg = ','.join(not_found)
reason = _('Fields absent in patch {}').format(msg)
raise exception.PatchError(reason=reason)
return valid_fields
@staticmethod
def _check_if_already_bound(context, valid_fields):
patch_fields = list(valid_fields.values())[0]
instance_uuid = patch_fields['instance_uuid']
extarqs = objects.ExtARQ.list(context)
extarqs_for_instance = [
extarq for extarq in extarqs
if extarq.arq['instance_uuid'] == instance_uuid]
if extarqs_for_instance: # duplicate binding request
msg = _('Instance {} already has accelerator requests. '
'Cannot bind additional ARQs.')
reason = msg.format(instance_uuid)
raise exception.PatchError(reason=reason)
@policy.authorize_wsgi("cyborg:arq", "update", False)
@expose.expose(None, body=types.jsontype,
status_code=http_client.ACCEPTED)
def patch(self, patch_list):
"""Bind/Unbind one or more ARQs.
Usage: curl -X PATCH .../v2/accelerator_requests
-d <patch_list> -H "Content-type: application/json"
:param patch_list: A map from ARQ UUIDs to their JSON patches:
{"$arq_uuid": [
{"path": "/hostname", "op": ADD/RM, "value": "..."},
{"path": "/device_rp_uuid", "op": ADD/RM, "value": "..."},
{"path": "/instance_uuid", "op": ADD/RM, "value": "..."},
{"path": "/project_id", "op": ADD/RM, "value": "..."},
],
"$arq_uuid": [...]
}
In particular, all and only these 3 fields must be present,
and only 'add' or 'remove' ops are allowed.
"""
LOG.info('[arqs] patch. list=(%s)', patch_list)
context = pecan.request.context
# Validate all patches before un/binding.
valid_fields = {}
for arq_uuid, patch in patch_list.items():
valid_fields[arq_uuid] = self._validate_arq_patch(patch)
# NOTE(Sundar): In the ARQ create/bind flow, new ARQs can be created
# for a device profile any time. However, they should not be bound to
# an instance which already has other ARQs bound to it. In the future,
# we may allow that for hot adds, but not now.
# See commit message of https://review.opendev.org/712231 for details.
#
# So, for bind requests, we first check that no ARQs are already
# associated with the instance specified in the binding.
patch = list(patch_list.values())[0]
if patch[0]['op'] == 'add':
self._check_if_already_bound(context, valid_fields)
# TODO(Sundar) Defer to conductor and do all concurently.
objects.ExtARQ.apply_patch(context, patch_list, valid_fields)