Browse Source

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-api
changes/93/499193/13
Clenimar Filemon 5 years ago committed by Clenimar Filemon
parent
commit
ec950be894
  1. 27
      magnum/api/attr_validator.py
  2. 12
      magnum/api/controllers/v1/__init__.py
  3. 454
      magnum/api/controllers/v1/federation.py
  4. 10
      magnum/api/validation.py
  5. 2
      magnum/cmd/conductor.py
  6. 5
      magnum/common/exception.py
  7. 2
      magnum/common/policies/__init__.py
  8. 91
      magnum/common/policies/federation.py
  9. 23
      magnum/conductor/api.py
  10. 32
      magnum/conductor/handlers/federation_conductor.py
  11. 15
      magnum/drivers/common/driver.py
  12. 9
      magnum/drivers/heat/driver.py
  13. 6
      magnum/objects/__init__.py
  14. 215
      magnum/objects/federation.py
  15. 26
      magnum/objects/fields.py
  16. 6
      magnum/tests/unit/api/controllers/test_root.py
  17. 415
      magnum/tests/unit/api/controllers/v1/test_federation.py
  18. 7
      magnum/tests/unit/api/utils.py
  19. 38
      magnum/tests/unit/conductor/handlers/test_federation_conductor.py
  20. 161
      magnum/tests/unit/objects/test_federation.py
  21. 1
      magnum/tests/unit/objects/test_objects.py
  22. 27
      magnum/tests/unit/objects/utils.py
  23. 11
      releasenotes/notes/add-federation-api-cf55d04f96772b0f.yaml

27
magnum/api/attr_validator.py

@ -210,6 +210,33 @@ def validate_master_count(cluster, cluster_template):
"master_count must be 1 when master_lb_enabled is False"))
def validate_federation_hostcluster(cluster_uuid):
"""Validate Federation `hostcluster_id` parameter.
If the parameter was not specified raise an
`exceptions.InvalidParameterValue`. If the specified identifier does not
identify any Cluster, raise `exception.ClusterNotFound`
"""
if cluster_uuid is not None:
api_utils.get_resource('Cluster', cluster_uuid)
else:
raise exception.InvalidParameterValue(
"No hostcluster specified. "
"Please specify a hostcluster_id.")
def validate_federation_properties(properties):
"""Validate Federation `properties` parameter."""
if properties is None:
raise exception.InvalidParameterValue(
"Please specify a `properties` "
"dict for the federation.")
# Currently, we only support the property `dns-zone`.
if properties.get('dns-zone') is None:
raise exception.InvalidParameterValue("No DNS zone specified. "
"Please specify a `dns-zone`.")
# Dictionary that maintains a list of validation functions
validators = {'image_id': validate_image,
'flavor_id': validate_flavor,

12
magnum/api/controllers/v1/__init__.py

@ -29,6 +29,7 @@ from magnum.api.controllers.v1 import baymodel
from magnum.api.controllers.v1 import certificate
from magnum.api.controllers.v1 import cluster
from magnum.api.controllers.v1 import cluster_template
from magnum.api.controllers.v1 import federation
from magnum.api.controllers.v1 import magnum_services
from magnum.api.controllers.v1 import quota
from magnum.api.controllers.v1 import stats
@ -99,6 +100,9 @@ class V1(controllers_base.APIBase):
stats = [link.Link]
"""Links to the stats resource"""
# Links to the federations resources
federations = [link.Link]
@staticmethod
def convert():
v1 = V1()
@ -161,6 +165,13 @@ class V1(controllers_base.APIBase):
pecan.request.host_url,
'stats', '',
bookmark=True)]
v1.federations = [link.Link.make_link('self', pecan.request.host_url,
'federations', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'federations', '',
bookmark=True)]
return v1
@ -175,6 +186,7 @@ class Controller(controllers_base.Controller):
certificates = certificate.CertificateController()
mservices = magnum_services.MagnumServiceController()
stats = stats.StatsController()
federations = federation.FederationsController()
@expose.expose(V1)
def get(self):

454
magnum/api/controllers/v1/federation.py

@ -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)

10
magnum/api/validation.py

@ -30,6 +30,7 @@ from magnum import objects
CONF = magnum.conf.CONF
cluster_update_allowed_properties = set(['node_count'])
federation_update_allowed_properties = set(['member_ids', 'properties'])
def enforce_cluster_type_supported():
@ -200,6 +201,15 @@ def validate_cluster_properties(delta):
raise exception.InvalidParameterValue(err=err)
def validate_federation_properties(delta):
update_disallowed_properties = delta - federation_update_allowed_properties
if update_disallowed_properties:
err = (_("cannot change federation property(ies) %s.") %
", ".join(update_disallowed_properties))
raise exception.InvalidParameterValue(err=err)
class Validator(object):
@classmethod

2
magnum/cmd/conductor.py

@ -28,6 +28,7 @@ from magnum.common import short_id
from magnum.conductor.handlers import ca_conductor
from magnum.conductor.handlers import cluster_conductor
from magnum.conductor.handlers import conductor_listener
from magnum.conductor.handlers import federation_conductor
from magnum.conductor.handlers import indirection_api
import magnum.conf
from magnum import version
@ -51,6 +52,7 @@ def main():
cluster_conductor.Handler(),
conductor_listener.Handler(),
ca_conductor.Handler(),
federation_conductor.Handler(),
]
server = rpc_service.Service.create(CONF.conductor.topic,

5
magnum/common/exception.py

@ -382,3 +382,8 @@ class FederationNotFound(ResourceNotFound):
class FederationAlreadyExists(Conflict):
message = _("A federation with UUID %(uuid)s already exists.")
class MemberAlreadyExists(Conflict):
message = _("A cluster with UUID %(uuid)s is already a member of the"
"federation %(federation_name)s.")

2
magnum/common/policies/__init__.py

@ -20,6 +20,7 @@ from magnum.common.policies import baymodel
from magnum.common.policies import certificate
from magnum.common.policies import cluster
from magnum.common.policies import cluster_template
from magnum.common.policies import federation
from magnum.common.policies import magnum_service
from magnum.common.policies import quota
from magnum.common.policies import stats
@ -33,6 +34,7 @@ def list_rules():
certificate.list_rules(),
cluster.list_rules(),
cluster_template.list_rules(),
federation.list_rules(),
magnum_service.list_rules(),
quota.list_rules(),
stats.list_rules()

91
magnum/common/policies/federation.py

@ -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

23
magnum/conductor/api.py

@ -51,6 +51,29 @@ class API(rpc_service.API):
def cluster_update_async(self, cluster, rollback=False):
self._cast('cluster_update', cluster=cluster, rollback=rollback)
# Federation Operations
def federation_create(self, federation, create_timeout):
return self._call('federation_create', federation=federation,
create_timeout=create_timeout)
def federation_create_async(self, federation, create_timeout):
self._cast('federation_create', federation=federation,
create_timeout=create_timeout)
def federation_delete(self, uuid):
return self._call('federation_delete', uuid=uuid)
def federation_delete_async(self, uuid):
self._cast('federation_delete', uuid=uuid)
def federation_update(self, federation):
return self._call('federation_update', federation=federation)
def federation_update_async(self, federation, rollback=False):
self._cast('federation_update', federation=federation,
rollback=rollback)
# CA operations
def sign_certificate(self, cluster, certificate):

32
magnum/conductor/handlers/federation_conductor.py

@ -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.")

15
magnum/drivers/common/driver.py

@ -173,6 +173,21 @@ class Driver(object):
raise NotImplementedError("Subclasses must implement "
"'delete_cluster'.")
@abc.abstractmethod
def create_federation(self, context, federation):
raise NotImplementedError("Subclasses must implement "
"'create_federation'.")
@abc.abstractmethod
def update_federation(self, context, federation):
raise NotImplementedError("Subclasses must implement "
"'update_federation'.")
@abc.abstractmethod
def delete_federation(self, context, federation):
raise NotImplementedError("Subclasses must implement "
"'delete_federation'.")
def get_monitor(self, context, cluster):
"""return the monitor with container data for this driver."""

9
magnum/drivers/heat/driver.py

@ -74,6 +74,15 @@ class HeatDriver(driver.Driver):
raise NotImplementedError("Must implement 'get_template_definition'")
def create_federation(self, context, federation):
return NotImplementedError("Must implement 'create_federation'")
def update_federation(self, context, federation):
return NotImplementedError("Must implement 'update_federation'")
def delete_federation(self, context, federation):
return NotImplementedError("Must implement 'delete_federation'")
def update_cluster_status(self, context, cluster):
if cluster.stack_id is None:
# NOTE(mgoddard): During cluster creation it is possible to poll

6
magnum/objects/__init__.py

@ -15,6 +15,7 @@
from magnum.objects import certificate
from magnum.objects import cluster
from magnum.objects import cluster_template
from magnum.objects import federation
from magnum.objects import magnum_service
from magnum.objects import quota
from magnum.objects import stats
@ -28,10 +29,13 @@ Quota = quota.Quota
X509KeyPair = x509keypair.X509KeyPair
Certificate = certificate.Certificate
Stats = stats.Stats
Federation = federation.Federation
__all__ = (Cluster,
ClusterTemplate,
MagnumService,
X509KeyPair,
Certificate,
Stats,
Quota)
Quota,
Federation
)

215
magnum/objects/federation.py

@ -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]

26
magnum/objects/fields.py

@ -49,6 +49,28 @@ class ClusterStatus(fields.Enum):
super(ClusterStatus, self).__init__(valid_values=ClusterStatus.ALL)
class FederationStatus(fields.Enum):
CREATE_IN_PROGRESS = 'CREATE_IN_PROGRESS'
CREATE_FAILED = 'CREATE_FAILED'
CREATE_COMPLETE = 'CREATE_COMPLETE'
UPDATE_IN_PROGRESS = 'UPDATE_IN_PROGRESS'
UPDATE_FAILED = 'UPDATE_FAILED'
UPDATE_COMPLETE = 'UPDATE_COMPLETE'
DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS'
DELETE_FAILED = 'DELETE_FAILED'
DELETE_COMPLETE = 'DELETE_COMPLETE'
ALL = (CREATE_IN_PROGRESS, CREATE_FAILED, CREATE_COMPLETE,
UPDATE_IN_PROGRESS, UPDATE_FAILED, UPDATE_COMPLETE,
DELETE_IN_PROGRESS, DELETE_FAILED, DELETE_COMPLETE)
STATUS_FAILED = (CREATE_FAILED, UPDATE_FAILED, DELETE_FAILED)
def __init__(self):
super(FederationStatus, self).__init__(
valid_values=FederationStatus.ALL)
class ContainerStatus(fields.Enum):
ALL = (
ERROR, RUNNING, STOPPED, PAUSED, UNKNOWN,
@ -146,3 +168,7 @@ class ClusterTypeField(fields.BaseEnumField):
class ServerTypeField(fields.BaseEnumField):
AUTO_TYPE = ServerType()
class FederationStatusField(fields.BaseEnumField):
AUTO_TYPE = FederationStatus()

6
magnum/tests/unit/api/controllers/test_root.py

@ -86,7 +86,11 @@ class TestRootController(api_base.FunctionalTest):
u'mservices': [{u'href': u'http://localhost/v1/mservices/',
u'rel': u'self'},
{u'href': u'http://localhost/mservices/',
u'rel': u'bookmark'}]}
u'rel': u'bookmark'}],
u'federations': [{u'href': u'http://localhost/v1/federations/',
u'rel': u'self'},
{u'href': u'http://localhost/federations/',
u'rel': u'bookmark'}]}
def make_app(self, paste_file):
file_name = self.get_path(paste_file)

415
magnum/tests/unit/api/controllers/v1/test_federation.py

@ -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)