federation api: api endpoints
this commit introduces a new '/federations' endpoint to Magnum API, as well as its controllers, entities and conductor handlers. this corresponds to the first phase of the federation-api spec. please refer to [1] for more details. [1] https://review.openstack.org/#/c/489609/ Change-Id: I662ac2d6ddec07b50712109541486fd26c5d21de Partially-Implements: blueprint federation-apichanges/93/499193/13
parent
710192a63f
commit
ec950be894
@ -0,0 +1,454 @@
|
||||
# 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 uuid
|
||||
|
||||
from oslo_log import log as logging
|
||||
import pecan
|
||||
import wsme
|
||||
from wsme import types as wtypes
|
||||
|
||||
from magnum.api import attr_validator
|
||||
from magnum.api.controllers import base
|
||||
from magnum.api.controllers import link
|
||||
from magnum.api.controllers.v1 import collection
|
||||
from magnum.api.controllers.v1 import types
|
||||
from magnum.api import expose
|
||||
from magnum.api import utils as api_utils
|
||||
from magnum.api import validation
|
||||
from magnum.common import exception
|
||||
from magnum.common import name_generator
|
||||
from magnum.common import policy
|
||||
import magnum.conf
|
||||
from magnum import objects
|
||||
from magnum.objects import fields
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CONF = magnum.conf.CONF
|
||||
|
||||
|
||||
class FederationID(wtypes.Base):
|
||||
"""API representation of a federation ID
|
||||
|
||||
This class enforces type checking and value constraints, and converts
|
||||
between the internal object model and the API representation of a
|
||||
federation ID.
|
||||
"""
|
||||
uuid = types.uuid
|
||||
|
||||
def __init__(self, uuid):
|
||||
self.uuid = uuid
|
||||
|
||||
|
||||
class Federation(base.APIBase):
|
||||
"""API representation of a federation.
|
||||
|
||||
This class enforces type checking and value constraints, and converts
|
||||
between the internal object model and the API representation of a
|
||||
Federation.
|
||||
"""
|
||||
|
||||
# Unique UUID for this federation.
|
||||
uuid = types.uuid
|
||||
|
||||
# Name of this federation, max length is limited to 242 because heat stack
|
||||
# requires max length limit to 255, and Magnum amend a uuid length.
|
||||
name = wtypes.StringType(min_length=1, max_length=242,
|
||||
pattern='^[a-zA-Z][a-zA-Z0-9_.-]*$')
|
||||
|
||||
# UUID of the hostcluster of the federation, i.e. the cluster that
|
||||
# hosts the COE Federated API.
|
||||
hostcluster_id = wsme.wsattr(wtypes.text)
|
||||
|
||||
# List of UUIDs of all the member clusters of the federation.
|
||||
member_ids = wsme.wsattr([wtypes.text])
|
||||
|
||||
# Status of the federation.
|
||||
status = wtypes.Enum(str, *fields.FederationStatus.ALL)
|
||||
|
||||
# Status reason of the federation.
|
||||
status_reason = wtypes.text
|
||||
|
||||
# Set of federation metadata (COE-specific in some cases).
|
||||
properties = wtypes.DictType(str, str)
|
||||
|
||||
# A list containing a self link and associated federations links
|
||||
links = wsme.wsattr([link.Link], readonly=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(Federation, self).__init__()
|
||||
self.fields = []
|
||||
for field in objects.Federation.fields:
|
||||
# Skip fields we do not expose.
|
||||
if not hasattr(self, field):
|
||||
continue
|
||||
self.fields.append(field)
|
||||
setattr(self, field, kwargs.get(field, wtypes.Unset))
|
||||
|
||||
@staticmethod
|
||||
def _convert_with_links(federation, url, expand=True):
|
||||
if not expand:
|
||||
federation.unset_fields_except(['uuid', 'name', 'hostcluster_id',
|
||||
'member_ids', 'status',
|
||||
'properties'])
|
||||
|
||||
federation.links = [link.Link.make_link('self', url, 'federations',
|
||||
federation.uuid),
|
||||
link.Link.make_link('bookmark', url, 'federations',
|
||||
federation.uuid,
|
||||
bookmark=True)]
|
||||
return federation
|
||||
|
||||
@classmethod
|
||||
def convert_with_links(cls, rpc_federation, expand=True):
|
||||
federation = Federation(**rpc_federation.as_dict())
|
||||
return cls._convert_with_links(federation, pecan.request.host_url,
|
||||
expand)
|
||||
|
||||
@classmethod
|
||||
def sample(cls, expand=True):
|
||||
sample = cls(uuid='4221a353-8368-475f-b7de-3429d3f724b3',
|
||||
name='example',
|
||||
hostcluster_id='49dc23f5-ffc9-40c3-9d34-7be7f9e34d63',
|
||||
member_ids=['49dc23f5-ffc9-40c3-9d34-7be7f9e34d63',
|
||||
'f2439bcf-02a2-4278-9d8a-f07a2042230a',
|
||||
'e549e0a5-3d3c-406f-bd7c-0e0182fb211c'],
|
||||
properties={'dns-zone': 'example.com.'},
|
||||
status=fields.FederationStatus.CREATE_COMPLETE,
|
||||
status_reason="CREATE completed successfully")
|
||||
return cls._convert_with_links(sample, 'http://localhost:9511', expand)
|
||||
|
||||
|
||||
class FederationPatchType(types.JsonPatchType):
|
||||
_api_base = Federation
|
||||
|
||||
@staticmethod
|
||||
def internal_attrs():
|
||||
""""Returns a list of internal attributes.
|
||||
|
||||
Internal attributes can't be added, replaced or removed.
|
||||
"""
|
||||
internal_attrs = []
|
||||
return types.JsonPatchType.internal_attrs() + internal_attrs
|
||||
|
||||
|
||||
class FederationCollection(collection.Collection):
|
||||
"""API representation of a collection of federations."""
|
||||
|
||||
# A list containing federation objects.
|
||||
federations = [Federation]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._type = 'federations'
|
||||
|
||||
@staticmethod
|
||||
def convert_with_links(rpc_federation, limit, url=None, expand=False,
|
||||
**kwargs):
|
||||
collection = FederationCollection()
|
||||
collection.federations = [Federation.convert_with_links(p, expand)
|
||||
for p in rpc_federation]
|
||||
collection.next = collection.get_next(limit, url=url, **kwargs)
|
||||
return collection
|
||||
|
||||
@classmethod
|
||||
def sample(cls):
|
||||
sample = cls()
|
||||
sample.federations = [Federation.sample(expand=False)]
|
||||
return sample
|
||||
|
||||
|
||||
class FederationsController(base.Controller):
|
||||
"""REST controller for federations."""
|
||||
|
||||
def __init__(self):
|
||||
super(FederationsController, self).__init__()
|
||||
|
||||
_custom_actions = {
|
||||
'detail': ['GET'],
|
||||
}
|
||||
|
||||
def _generate_name_for_federation(self, context):
|
||||
"""Generate a random name like: phi-17-federation."""
|
||||
name_gen = name_generator.NameGenerator()
|
||||
name = name_gen.generate()
|
||||
return name + '-federation'
|
||||
|
||||
def _get_federation_collection(self, marker, limit,
|
||||
sort_key, sort_dir, expand=False,
|
||||
resource_url=None):
|
||||
limit = api_utils.validate_limit(limit)
|
||||
sort_dir = api_utils.validate_sort_dir(sort_dir)
|
||||
|
||||
marker_obj = None
|
||||
if marker:
|
||||
marker_obj = objects.Federation.get_by_uuid(pecan.request.context,
|
||||
marker)
|
||||
|
||||
federations = objects.Federation.list(pecan.request.context, limit,
|
||||
marker_obj, sort_key=sort_key,
|
||||
sort_dir=sort_dir)
|
||||
|
||||
return FederationCollection.convert_with_links(federations, limit,
|
||||
url=resource_url,
|
||||
expand=expand,
|
||||
sort_key=sort_key,
|
||||
sort_dir=sort_dir)
|
||||
|
||||
@expose.expose(FederationCollection, types.uuid, int, wtypes.text,
|
||||
wtypes.text)
|
||||
def get_all(self, marker=None, limit=None, sort_key='id',
|
||||
sort_dir='asc'):
|
||||
"""Retrieve a list of federations.
|
||||
|
||||
:param marker: pagination marker for large data sets.
|
||||
:param limit: maximum number of resources to return in a single result.
|
||||
:param sort_key: column to sort results by. Default: id.
|
||||
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
|
||||
"""
|
||||
context = pecan.request.context
|
||||
policy.enforce(context, 'federation:get_all',
|
||||
action='federation:get_all')
|
||||
return self._get_federation_collection(marker, limit, sort_key,
|
||||
sort_dir)
|
||||
|
||||
@expose.expose(FederationCollection, types.uuid, int, wtypes.text,
|
||||
wtypes.text)
|
||||
def detail(self, marker=None, limit=None, sort_key='id',
|
||||
sort_dir='asc'):
|
||||
"""Retrieve a list of federation with detail.
|
||||
|
||||
:param marker: pagination marker for large data sets.
|
||||
:param limit: maximum number of resources to return in a single result.
|
||||
:param sort_key: column to sort results by. Default: id.
|
||||
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
|
||||
"""
|
||||
context = pecan.request.context
|
||||
policy.enforce(context, 'federation:detail',
|
||||
action='federation:detail')
|
||||
|
||||
# NOTE(lucasagomes): /detail should only work against collections
|
||||
parent = pecan.request.path.split('/')[:-1][-1]
|
||||
if parent != "federations":
|
||||
raise exception.HTTPNotFound
|
||||
|
||||
expand = True
|
||||
resource_url = '/'.join(['federations', 'detail'])
|
||||
return self._get_federation_collection(marker, limit,
|
||||
sort_key, sort_dir, expand,
|
||||
resource_url)
|
||||
|
||||
@expose.expose(Federation, types.uuid_or_name)
|
||||
def get_one(self, federation_ident):
|
||||
"""Retrieve information about a given Federation.
|
||||
|
||||
:param federation_ident: UUID or logical name of the Federation.
|
||||
"""
|
||||
context = pecan.request.context
|
||||
federation = api_utils.get_resource('Federation', federation_ident)
|
||||
policy.enforce(context, 'federation:get', federation.as_dict(),
|
||||
action='federation:get')
|
||||
|
||||
federation = Federation.convert_with_links(federation)
|
||||
|
||||
return federation
|
||||
|
||||
@expose.expose(FederationID, body=Federation, status_code=202)
|
||||
def post(self, federation):
|
||||
"""Create a new federation.
|
||||
|
||||
:param federation: a federation within the request body.
|
||||
"""
|
||||
context = pecan.request.context
|
||||
policy.enforce(context, 'federation:create',
|
||||
action='federation:create')
|
||||
|
||||
federation_dict = federation.as_dict()
|
||||
|
||||
# Validate `hostcluster_id`
|
||||
hostcluster_id = federation_dict.get('hostcluster_id')
|
||||
attr_validator.validate_federation_hostcluster(hostcluster_id)
|
||||
|
||||
# Validate `properties` dict.
|
||||
properties_dict = federation_dict.get('properties')
|
||||
attr_validator.validate_federation_properties(properties_dict)
|
||||
|
||||
federation_dict['project_id'] = context.project_id
|
||||
|
||||
# If no name is specified, generate a random human-readable name
|
||||
name = (federation_dict.get('name') or
|
||||
self._generate_name_for_federation(context))
|
||||
federation_dict['name'] = name
|
||||
|
||||
new_federation = objects.Federation(context, **federation_dict)
|
||||
new_federation.uuid = uuid.uuid4()
|
||||
|
||||
# TODO(clenimar): remove hard-coded `create_timeout`.
|
||||
pecan.request.rpcapi.federation_create_async(new_federation,
|
||||
create_timeout=15)
|
||||
|
||||
return FederationID(new_federation.uuid)
|
||||
|
||||
@expose.expose(FederationID, types.uuid_or_name, types.boolean,
|
||||
body=[FederationPatchType], status_code=202)
|
||||
def patch(self, federation_ident, rollback=False, patch=None):
|
||||
"""Update an existing Federation.
|
||||
|
||||
Please note that the join/unjoin operation is performed by patching
|
||||
`member_ids`.
|
||||
|
||||
:param federation_ident: UUID or logical name of a federation.
|
||||
:param rollback: whether to rollback federation on update failure.
|
||||
:param patch: a json PATCH document to apply to this federation.
|
||||
"""
|
||||
federation = self._patch(federation_ident, patch)
|
||||
pecan.request.rpcapi.federation_update_async(federation, rollback)
|
||||
return FederationID(federation.uuid)
|
||||
|
||||
def _patch(self, federation_ident, patch):
|
||||
context = pecan.request.context
|
||||
federation = api_utils.get_resource('Federation', federation_ident)
|
||||
policy.enforce(context, 'federation:update', federation.as_dict(),
|
||||
action='federation:update')
|
||||
|
||||
# NOTE(clenimar): Magnum does not allow one to append items to existing
|
||||
# fields through an `add` operation using HTTP PATCH (please check
|
||||
# `magnum.api.utils.apply_jsonpatch`). In order to perform the join
|
||||
# and unjoin operations, intercept the original JSON PATCH document
|
||||
# and change the operation from either `add` or `remove` to `replace`.
|
||||
patch_path = patch[0].get('path')
|
||||
patch_value = patch[0].get('value')
|
||||
patch_op = patch[0].get('op')
|
||||
|
||||
if patch_path == '/member_ids':
|
||||
if patch_op == 'add' and patch_value is not None:
|
||||
patch = self._join_wrapper(federation_ident, patch)
|
||||
elif patch_op == 'remove' and patch_value is not None:
|
||||
patch = self._unjoin_wrapper(federation_ident, patch)
|
||||
|
||||
try:
|
||||
federation_dict = federation.as_dict()
|
||||
new_federation = Federation(
|
||||
**api_utils.apply_jsonpatch(federation_dict, patch))
|
||||
except api_utils.JSONPATCH_EXCEPTIONS as e:
|
||||
raise exception.PatchError(patch=patch, reason=e)
|
||||
|
||||
# Retrieve only what changed after the patch.
|
||||
delta = self._update_changed_fields(federation, new_federation)
|
||||
validation.validate_federation_properties(delta)
|
||||
|
||||
return federation
|
||||
|
||||
def _update_changed_fields(self, federation, new_federation):
|
||||
"""Update only the patches that were modified and return the diff."""
|
||||
for field in objects.Federation.fields:
|
||||
try:
|
||||
patch_val = getattr(new_federation, field)
|
||||
except AttributeError:
|
||||
# Ignore fields that aren't exposed in the API
|
||||
continue
|
||||
if patch_val == wtypes.Unset:
|
||||
patch_val = None
|
||||
if federation[field] != patch_val:
|
||||
federation[field] = patch_val
|
||||
|
||||
return federation.obj_what_changed()
|
||||
|
||||
def _join_wrapper(self, federation_ident, patch):
|
||||
"""Intercept PATCH JSON documents for join operations.
|
||||
|
||||
Take a PATCH JSON document with `add` operation::
|
||||
{
|
||||
'op': 'add',
|
||||
'value': 'new_member_id',
|
||||
'path': '/member_ids'
|
||||
}
|
||||
and transform it into a document with `replace` operation::
|
||||
{
|
||||
'op': 'replace',
|
||||
'value': ['current_member_id1', ..., 'new_member_id'],
|
||||
'path': '/member_ids'
|
||||
}
|
||||
"""
|
||||
federation = api_utils.get_resource('Federation', federation_ident)
|
||||
new_member_uuid = patch[0]['value']
|
||||
|
||||
# Check if the cluster exists
|
||||
c = objects.Cluster.get_by_uuid(pecan.request.context, new_member_uuid)
|
||||
|
||||
# Check if the cluster is already a member of the federation
|
||||
if new_member_uuid not in federation.member_ids and c is not None:
|
||||
# Retrieve all current members
|
||||
members = federation.member_ids
|
||||
# Add the new member
|
||||
members.append(c.uuid)
|
||||
else:
|
||||
kw = {'uuid': new_member_uuid, 'federation_name': federation.name}
|
||||
raise exception.MemberAlreadyExists(**kw)
|
||||
|
||||
# Set `value` to the updated member list. Change `op` to `replace`
|
||||
patch[0]['value'] = members
|
||||
patch[0]['op'] = 'replace'
|
||||
|
||||
return patch
|
||||
|
||||
def _unjoin_wrapper(self, federation_ident, patch):
|
||||
"""Intercept PATCH JSON documents for unjoin operations.
|
||||
|
||||
Take a PATCH JSON document with `remove` operation::
|
||||
{
|
||||
'op': 'remove',
|
||||
'value': 'former_member_id',
|
||||
'path': '/member_ids'
|
||||
}
|
||||
and transform it into a document with `replace` operation::
|
||||
{
|
||||
'op': 'replace',
|
||||
'value': ['current_member_id1', ..., 'current_member_idn'],
|
||||
'path': '/member_ids'
|
||||
}
|
||||
"""
|
||||
federation = api_utils.get_resource('Federation', federation_ident)
|
||||
cluster_uuid = patch[0]['value']
|
||||
|
||||
# Check if the cluster exists
|
||||
c = objects.Cluster.get_by_uuid(pecan.request.context, cluster_uuid)
|
||||
|
||||
# Check if the cluster is a member cluster and if it exists
|
||||
if cluster_uuid in federation.member_ids and c is not None:
|
||||
# Retrieve all current members
|
||||
members = federation.member_ids
|
||||
# Unjoin the member
|
||||
members.remove(cluster_uuid)
|
||||
else:
|
||||
raise exception.HTTPNotFound("Cluster %s is not a member of the "
|
||||
"federation %s." % (cluster_uuid,
|
||||
federation.name))
|
||||
|
||||
# Set `value` to the updated member list. Change `op` to `replace`
|
||||
patch[0]['value'] = members
|
||||
patch[0]['op'] = 'replace'
|
||||
|
||||
return patch
|
||||
|
||||
@expose.expose(None, types.uuid_or_name, status_code=204)
|
||||
def delete(self, federation_ident):
|
||||
"""Delete a federation.
|
||||
|
||||
:param federation_ident: UUID of federation or logical name
|
||||
of the federation.
|
||||
"""
|
||||
context = pecan.request.context
|
||||
federation = api_utils.get_resource('Federation', federation_ident)
|
||||
policy.enforce(context, 'federation:delete', federation.as_dict(),
|
||||
action='federation:delete')
|
||||
|
||||
pecan.request.rpcapi.federation_delete_async(federation.uuid)
|
@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
from oslo_policy import policy
|
||||
|
||||
from magnum.common.policies import base
|
||||
|
||||
FEDERATION = 'federation:%s'
|
||||
|
||||
rules = [
|
||||
policy.DocumentedRuleDefault(
|
||||
name=FEDERATION % 'create',
|
||||
check_str=base.RULE_DENY_CLUSTER_USER,
|
||||
description='Create a new federation.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/federations',
|
||||
'method': 'POST'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=FEDERATION % 'delete',
|
||||
check_str=base.RULE_DENY_CLUSTER_USER,
|
||||
description='Delete a federation.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/federations/{federation_ident}',
|
||||
'method': 'DELETE'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=FEDERATION % 'detail',
|
||||
check_str=base.RULE_DENY_CLUSTER_USER,
|
||||
description='Retrieve a list of federations with detail.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/federations',
|
||||
'method': 'GET'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=FEDERATION % 'get',
|
||||
check_str=base.RULE_DENY_CLUSTER_USER,
|
||||
description='Retrieve information about the given federation.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/federations/{federation_ident}',
|
||||
'method': 'GET'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=FEDERATION % 'get_all',
|
||||
check_str=base.RULE_DENY_CLUSTER_USER,
|
||||
description='Retrieve a list of federations.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/federations/',
|
||||
'method': 'GET'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=FEDERATION % 'update',
|
||||
check_str=base.RULE_DENY_CLUSTER_USER,
|
||||
description='Update an existing federation.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/federations/{federation_ident}',
|
||||
'method': 'PATCH'
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def list_rules():
|
||||
return rules
|
@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
|
||||
from magnum.common import profiler
|
||||
import magnum.conf
|
||||
|
||||
CONF = magnum.conf.CONF
|
||||
|
||||
|
||||
@profiler.trace_cls("rpc")
|
||||
class Handler(object):
|
||||
|
||||
def __init__(self):
|
||||
super(Handler, self).__init__()
|
||||
|
||||
def federation_create(self, context, federation, create_timeout):
|
||||
raise NotImplementedError("This feature is not yet implemented.")
|
||||
|
||||
def federation_update(self, context, federation, rollback=False):
|
||||
raise NotImplementedError("This feature is not yet implemented.")
|
||||
|
||||
def federation_delete(self, context, uuid):
|
||||
raise NotImplementedError("This feature is not yet implemented.")
|
@ -0,0 +1,215 @@
|
||||
# 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.
|
||||
|
||||
from oslo_utils import strutils
|
||||
from oslo_utils import uuidutils
|
||||
from oslo_versionedobjects import fields
|
||||
|
||||
from magnum.common import exception
|
||||
from magnum.db import api as dbapi
|
||||
from magnum.objects import base
|
||||
from magnum.objects import fields as m_fields
|
||||
|
||||
|
||||
@base.MagnumObjectRegistry.register
|
||||
class Federation(base.MagnumPersistentObject, base.MagnumObject,
|
||||
base.MagnumObjectDictCompat):
|
||||
"""Represents a Federation object.
|
||||
|
||||
Version 1.0: Initial Version
|
||||
"""
|
||||
|
||||
VERSION = '1.0'
|
||||
|
||||
dbapi = dbapi.get_instance()
|
||||
|
||||
fields = {
|
||||
'id': fields.IntegerField(),
|
||||
'uuid': fields.UUIDField(nullable=True),
|
||||
'name': fields.StringField(nullable=True),
|
||||
'project_id': fields.StringField(nullable=True),
|
||||
'hostcluster_id': fields.StringField(nullable=True),
|
||||
'member_ids': fields.ListOfStringsField(nullable=True),
|
||||
'status': m_fields.FederationStatusField(nullable=True),
|
||||
'status_reason': fields.StringField(nullable=True),
|
||||
'properties': fields.DictOfStringsField(nullable=True)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _from_db_object(federation, db_federation):
|
||||
"""Converts a database entity to a formal object."""
|
||||
for field in federation.fields:
|
||||
federation[field] = db_federation[field]
|
||||
|
||||
federation.obj_reset_changes()
|
||||
return federation
|
||||
|
||||
@staticmethod
|
||||
def _from_db_object_list(db_objects, cls, context):
|
||||
"""Converts a list of database entities to a list of formal objects."""
|
||||
return [Federation._from_db_object(cls(context), obj)
|
||||
for obj in db_objects]
|
||||
|
||||
@base.remotable_classmethod
|
||||
def get(cls, context, federation_id):
|
||||
"""Find a federation based on its id or uuid and return it.
|
||||
|
||||
:param federation_id: the id *or* uuid of a federation.
|
||||
:param context: Security context
|
||||
:returns: a :class:`Federation` object.
|
||||
"""
|
||||
if strutils.is_int_like(federation_id):
|
||||
return cls.get_by_id(context, federation_id)
|
||||
elif uuidutils.is_uuid_like(federation_id):
|
||||
return cls.get_by_uuid(context, federation_id)
|
||||
else:
|
||||
raise exception.InvalidIdentity(identity=federation_id)
|
||||
|
||||
@base.remotable_classmethod
|
||||
def get_by_id(cls, context, federation_id):
|
||||
"""Find a federation based on its integer id and return it.
|
||||
|
||||
:param federation_id: the id of a federation.
|
||||
:param context: Security context
|
||||
:returns: a :class:`Federation` object.
|
||||
"""
|
||||
db_federation = cls.dbapi.get_federation_by_id(context, federation_id)
|
||||
federation = Federation._from_db_object(cls(context), db_federation)
|
||||
return federation
|
||||
|
||||
@base.remotable_classmethod
|
||||
def get_by_uuid(cls, context, uuid):
|
||||
"""Find a federation based on uuid and return it.
|
||||
|
||||
:param uuid: the uuid of a federation.
|
||||
:param context: Security context
|
||||
:returns: a :class:`Federation` object.
|
||||
"""
|
||||
db_federation = cls.dbapi.get_federation_by_uuid(context, uuid)
|
||||
federation = Federation._from_db_object(cls(context), db_federation)
|
||||
return federation
|
||||
|
||||
@base.remotable_classmethod
|
||||
def get_count_all(cls, context, filters=None):
|
||||
"""Get count of matching federation.
|
||||
|
||||
:param context: The security context
|
||||
:param filters: filter dict, can includes 'name', 'project_id',
|
||||
'hostcluster_id', 'member_ids', 'status' (should be a
|
||||
status list).
|
||||
:returns: Count of matching federation.
|
||||
"""
|
||||
return cls.dbapi.get_federation_count_all(context, filters=filters)
|
||||
|
||||
@base.remotable_classmethod
|
||||
def get_by_name(cls, context, name):
|
||||
"""Find a federation based on name and return a Federation object.
|
||||
|
||||
:param name: the logical name of a federation.
|
||||
:param context: Security context
|
||||
:returns: a :class:`Federation` object.
|
||||
"""
|
||||
db_federation = cls.dbapi.get_federation_by_name(context, name)
|
||||
federation = Federation._from_db_object(cls(context), db_federation)
|
||||
return federation
|
||||
|
||||
@base.remotable_classmethod
|
||||
def list(cls, context, limit=None, marker=None,
|
||||
sort_key=None, sort_dir=None, filters=None):
|
||||
"""Return a list of Federation objects.
|
||||
|
||||
:param context: Security context.
|
||||
:param limit: maximum number of resources to return in a single result.
|
||||
:param marker: pagination marker for large data sets.
|
||||
:param sort_key: column to sort results by.
|
||||
:param sort_dir: direction to sort. "asc" or "desc".
|
||||
:param filters: filter dict, can includes 'name', 'project_id',
|
||||
'hostcluster_id', 'member_ids', 'status' (should be a
|
||||
status list).
|
||||
:returns: a list of :class:`Federation` object.
|
||||
|
||||
"""
|
||||
db_federation = cls.dbapi.get_federation_list(context, limit=limit,
|
||||
marker=marker,
|
||||
sort_key=sort_key,
|
||||
sort_dir=sort_dir,
|
||||
filters=filters)
|
||||
return Federation._from_db_object_list(db_federation, cls, context)
|
||||
|
||||
@base.remotable
|
||||
def create(self, context=None):
|
||||
"""Create a Federation record in the DB.
|
||||
|
||||
:param context: Security context. NOTE: This should only
|
||||
be used internally by the indirection_api.
|
||||
Unfortunately, RPC requires context as the first
|
||||
argument, even though we don't use it.
|
||||
A context should be set when instantiating the
|
||||
object, e.g.: Federation(context)
|
||||
|
||||
"""
|
||||
values = self.obj_get_changes()
|
||||
db_federation = self.dbapi.create_federation(values)
|
||||
self._from_db_object(self, db_federation)
|
||||
|
||||
@base.remotable
|
||||
def destroy(self, context=None):
|
||||
"""Delete the Federation from the DB.
|
||||
|
||||
:param context: Security context. NOTE: This should only
|
||||
be used internally by the indirection_api.
|
||||
Unfortunately, RPC requires context as the first
|
||||
argument, even though we don't use it.
|
||||
A context should be set when instantiating the
|
||||
object, e.g.: Federation(context)
|
||||
"""
|
||||
self.dbapi.destroy_federation(self.uuid)
|
||||
self.obj_reset_changes()
|
||||
|
||||
@base.remotable
|
||||
def save(self, context=None):
|
||||
"""Save updates to this Federation.
|
||||
|
||||
Updates will be made column by column based on the result
|
||||
of self.what_changed().
|
||||
|
||||
:param context: Security context. NOTE: This should only
|
||||
be used internally by the indirection_api.
|
||||
Unfortunately, RPC requires context as the first
|
||||
argument, even though we don't use it.
|
||||
A context should be set when instantiating the
|
||||
object, e.g.: Federation(context)
|
||||
"""
|
||||
updates = self.obj_get_changes()
|
||||
self.dbapi.update_federation(self.uuid, updates)
|
||||
|
||||
self.obj_reset_changes()
|
||||
|
||||
@base.remotable
|
||||
def refresh(self, context=None):
|
||||
"""Load updates for this Federation.
|
||||
|
||||
Loads a Federation with the same uuid from the database and
|
||||
checks for updated attributes. Updates are applied from
|
||||
the loaded Federation column by column, if there are any updates.
|
||||
|
||||
:param context: Security context. NOTE: This should only
|
||||
be used internally by the indirection_api.
|
||||
Unfortunately, RPC requires context as the first
|
||||
argument, even though we don't use it.
|
||||
A context should be set when instantiating the
|
||||
object, e.g.: Federation(context)
|
||||
"""
|
||||
current = self.__class__.get_by_uuid(self._context, uuid=self.uuid)
|
||||
for field in self.fields:
|
||||
if self.obj_attr_is_set(field) and self[field] != current[field]:
|
||||
self[field] = current[field]
|
@ -0,0 +1,415 @@
|
||||
# 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 datetime
|
||||
import mock
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from magnum.api.controllers.v1 import federation as api_federation
|
||||
from magnum.conductor import api as rpcapi
|
||||
import magnum.conf
|
||||
from magnum import objects
|
||||
from magnum.tests import base
|
||||
from magnum.tests.unit.api import base as api_base
|
||||
from magnum.tests.unit.api import utils as apiutils
|
||||
from magnum.tests.unit.objects import utils as obj_utils
|
||||
|
||||
CONF = magnum.conf.CONF
|
||||
|
||||
|
||||
class TestFederationObject(base.TestCase):
|
||||
def test_federation_init(self):
|
||||
fed_dict = apiutils.federation_post_data()
|
||||
fed_dict['uuid'] = uuidutils.generate_uuid()
|
||||
federation = api_federation.Federation(**fed_dict)
|
||||
self.assertEqual(fed_dict['uuid'], federation.uuid)
|
||||
|
||||
|
||||
class TestListFederation(api_base.FunctionalTest):
|
||||
def setUp(self):
|
||||
super(TestListFederation, self).setUp()
|
||||
|
||||
def test_empty(self):
|
||||
response = self.get_json('/federations')
|
||||
self.assertEqual(response['federations'], [])
|
||||
|
||||
def test_one(self):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations')
|
||||
self.assertEqual(federation.uuid, response['federations'][0]['uuid'])
|
||||
|
||||
def test_get_one(self):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations/%s' % federation['uuid'])
|
||||
self.assertTrue(response['uuid'], federation.uuid)
|
||||
|
||||
def test_get_one_by_name(self):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations/%s' % federation['name'])
|
||||
self.assertTrue(response['uuid'], federation.uuid)
|
||||
|
||||
def test_get_one_by_name_not_found(self):
|
||||
response = self.get_json('/federations/not_found', expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['errors'])
|
||||
|
||||
def test_get_one_by_uuid(self):
|
||||
temp_uuid = uuidutils.generate_uuid()
|
||||
federation = obj_utils.create_test_federation(self.context,
|
||||
uuid=temp_uuid)
|
||||
response = self.get_json('/federations/%s' % temp_uuid)
|
||||
self.assertTrue(response['uuid'], federation.uuid)
|
||||
|
||||
def test_get_one_by_uuid_not_found(self):
|
||||
temp_uuid = uuidutils.generate_uuid()
|
||||
response = self.get_json('/federations/%s' % temp_uuid,
|
||||
expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['errors'])
|
||||
|
||||
def test_get_one_by_name_multiple_federation(self):
|
||||
obj_utils.create_test_federation(self.context, name='test_federation',
|
||||
uuid=uuidutils.generate_uuid())
|
||||
obj_utils.create_test_federation(self.context, name='test_federation',
|
||||
uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations/test_federation',
|
||||
expect_errors=True)
|
||||
self.assertEqual(409, response.status_int)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['errors'])
|
||||
|
||||
def test_get_all_with_pagination_marker(self):
|
||||
federation_list = []
|
||||
for id_ in range(4):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, id=id_, uuid=uuidutils.generate_uuid())
|
||||
federation_list.append(federation)
|
||||
|
||||
response = self.get_json(
|
||||
'/federations?limit=3&marker=%s' % federation_list[2].uuid)
|
||||
self.assertEqual(1, len(response['federations']))
|
||||
self.assertEqual(federation_list[-1].uuid,
|
||||
response['federations'][0]['uuid'])
|
||||
|
||||
def test_detail(self):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations/detail')
|
||||
self.assertEqual(federation.uuid, response['federations'][0]["uuid"])
|
||||
|
||||
def test_detail_with_pagination_marker(self):
|
||||
federation_list = []
|
||||
for id_ in range(4):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, id=id_, uuid=uuidutils.generate_uuid())
|
||||
federation_list.append(federation)
|
||||
|
||||
response = self.get_json(
|
||||
'/federations/detail?limit=3&marker=%s' % federation_list[2].uuid)
|
||||
self.assertEqual(1, len(response['federations']))
|
||||
self.assertEqual(federation_list[-1].uuid,
|
||||
response['federations'][0]['uuid'])
|
||||
|
||||
def test_detail_against_single(self):
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json(
|
||||
'/federations/%s/detail' % federation['uuid'], expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['errors'])
|
||||
|
||||
def test_many(self):
|
||||
federation_list = []
|
||||
for id_ in range(5):
|
||||
temp_uuid = uuidutils.generate_uuid()
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, id=id_, uuid=temp_uuid)
|
||||
federation_list.append(federation.uuid)
|
||||
|
||||
response = self.get_json('/federations')
|
||||
self.assertEqual(len(federation_list), len(response['federations']))
|
||||
uuids = [f['uuid'] for f in response['federations']]
|
||||
self.assertEqual(sorted(federation_list), sorted(uuids))
|
||||
|
||||
def test_links(self):
|
||||
uuid = uuidutils.generate_uuid()
|
||||
obj_utils.create_test_federation(self.context, id=1, uuid=uuid)
|
||||
response = self.get_json('/federations/%s' % uuid)
|
||||
self.assertIn('links', response.keys())
|
||||
self.assertEqual(2, len(response['links']))
|
||||
self.assertIn(uuid, response['links'][0]['href'])
|
||||
for l in response['links']:
|
||||
bookmark = l['rel'] == 'bookmark'
|
||||
self.assertTrue(self.validate_link(l['href'],
|
||||
bookmark=bookmark))
|
||||
|
||||
def test_collection_links(self):
|
||||
for id_ in range(5):
|
||||
obj_utils.create_test_federation(self.context, id=id_,
|
||||
uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations/?limit=3')
|
||||
next_marker = response['federations'][-1]['uuid']
|
||||
self.assertIn(next_marker, response['next'])
|
||||
|
||||
def test_collection_links_default_limit(self):
|
||||
cfg.CONF.set_override('max_limit', 3, 'api')
|
||||
for id_ in range(5):
|
||||
obj_utils.create_test_federation(self.context, id=id_,
|
||||
uuid=uuidutils.generate_uuid())
|
||||
response = self.get_json('/federations')
|
||||
self.assertEqual(3, len(response['federations']))
|
||||
|
||||
next_marker = response['federations'][-1]['uuid']
|
||||
self.assertIn(next_marker, response['next'])
|
||||
|
||||
|
||||
class TestPatch(api_base.FunctionalTest):
|
||||
def setUp(self):
|
||||
super(TestPatch, self).setUp()
|
||||
p = mock.patch.object(rpcapi.API, 'federation_update_async')
|
||||
self.mock_federation_update = p.start()
|
||||
self.mock_federation_update.side_effect = \
|
||||
self._sim_rpc_federation_update
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def _sim_rpc_federation_update(self, federation, rollback=False):
|
||||
federation.save()
|
||||
return federation
|
||||
|
||||
def test_member_join(self):
|
||||
f = obj_utils.create_test_federation(
|
||||
self.context, name='federation-example',
|
||||
uuid=uuidutils.generate_uuid(), member_ids=[])
|
||||
new_member = obj_utils.create_test_cluster(self.context)
|
||||
|
||||
response = self.patch_json(
|
||||
'/federations/%s' % f.uuid,
|
||||
[{'path': '/member_ids', 'value': new_member.uuid, 'op': 'add'}])
|
||||
self.assertEqual(202, response.status_int)
|
||||
|
||||
# make sure it was added:
|
||||
fed = self.get_json('/federations/%s' % f.uuid)
|
||||
self.assertTrue(new_member.uuid in fed['member_ids'])
|
||||
|
||||
def test_member_unjoin(self):
|
||||
member = obj_utils.create_test_cluster(self.context)
|
||||
federation = obj_utils.create_test_federation(
|
||||
self.context, name='federation-example',
|
||||
uuid=uuidutils.generate_uuid(), member_ids=[member.uuid])
|
||||
|
||||
response = self.patch_json(
|
||||
'/federations/%s' % federation.uuid,
|
||||
[{'path': '/member_ids', 'value': member.uuid, 'op': 'remove'}])
|
||||
self.assertEqual(202, response.status_int)
|
||||
|
||||
# make sure it was deleted:
|
||||
fed = self.get_json('/federations/%s' % federation.uuid)
|
||||
self.assertFalse(member.uuid in fed['member_ids'])
|
||||
|
||||
def test_join_non_existent_cluster(self):
|
||||
foo_uuid = uuidutils.generate_uuid()
|
||||
f = obj_utils.create_test_federation(
|
||||
self.context, name='federation-example',
|
||||
uuid=uuidutils.generate_uuid(), member_ids=[])
|
||||
|
||||
response = self.patch_json(
|
||||
'/federations/%s' % f.uuid,
|
||||
[{'path': '/member_ids', 'value': foo_uuid, 'op': 'add'}],
|
||||
expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
|
||||
def test_unjoin_non_existent_cluster(self):
|
||||
foo_uuid = uuidutils.generate_uuid()
|
||||
f = obj_utils.create_test_federation(
|
||||
self.context, name='federation-example',
|
||||
uuid=uuidutils.generate_uuid(), member_ids=[])
|
||||
|
||||
response = self.patch_json(
|
||||
'/federations/%s' % f.uuid,
|
||||
[{'path': '/member_ids', 'value': foo_uuid, 'op': 'remove'}],
|
||||
expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
|
||||
def test_join_cluster_already_member(self):
|
||||
cluster = obj_utils.create_test_cluster(self.context)
|
||||
f = obj_utils.create_test_federation(
|
||||
self.context, name='federation-example',
|
||||
uuid=uuidutils.generate_uuid(), member_ids=[cluster.uuid])
|
||||
|
||||
response = self.patch_json(
|
||||
'/federations/%s' % f.uuid,
|
||||
[{'path': '/member_ids', 'value': cluster.uuid, 'op': 'add'}],
|
||||
expect_errors=True)
|
||||
self.assertEqual(409, response.status_int)
|
||||
|
||||
def test_unjoin_non_member_cluster(self):
|
||||
cluster = obj_utils.create_test_cluster(self.context)
|
||||
f = obj_utils.create_test_federation(
|
||||
self.context, name='federation-example',
|
||||
uuid=uuidutils.generate_uuid(), member_ids=[])
|
||||
|
||||
response = self.patch_json(
|
||||
'/federations/%s' % f.uuid,
|
||||
[{'path': '/member_ids', 'value': cluster.uuid, 'op': 'remove'}],
|
||||
expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
|
||||
|
||||
class TestPost(api_base.FunctionalTest):
|
||||
def setUp(self):
|
||||
super(TestPost, self).setUp()
|
||||
p = mock.patch.object(rpcapi.API, 'federation_create_async')
|
||||
self.mock_fed_create = p.start()
|
||||
self.mock_fed_create.side_effect = self._simulate_federation_create
|
||||
self.addCleanup(p.stop)
|
||||
self.hostcluster = obj_utils.create_test_cluster(self.context)
|
||||
|
||||
def _simulate_federation_create(self, federation, create_timeout):
|
||||
federation.create()
|
||||
return federation
|
||||
|
||||
@mock.patch('oslo_utils.timeutils.utcnow')
|
||||
def test_create_federation(self, mock_utcnow):
|
||||
bdict = apiutils.federation_post_data(
|
||||
uuid=uuidutils.generate_uuid(),
|
||||
hostcluster_id=self.hostcluster.uuid)
|
||||
test_time = datetime.datetime(2000, 1, 1, 0, 0)
|
||||
mock_utcnow.return_value = test_time
|
||||
|
||||
response = self.post_json('/federations', bdict)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(202, response.status_int)
|
||||
self.assertTrue(uuidutils.is_uuid_like(response.json['uuid'] |