diff --git a/heat/engine/clients/os/blazar.py b/heat/engine/clients/os/blazar.py index 3095d46518..099c6af5ba 100644 --- a/heat/engine/clients/os/blazar.py +++ b/heat/engine/clients/os/blazar.py @@ -12,7 +12,7 @@ # under the License. from blazarclient import client as blazar_client -from keystoneauth1.exceptions import http as ks_exc +from blazarclient import exception as client_exception from heat.engine.clients import client_plugin @@ -36,7 +36,23 @@ class BlazarClientPlugin(client_plugin.ClientPlugin): return client def is_not_found(self, exc): - return isinstance(exc, ks_exc.NotFound) + # TODO(asmita): Implement exception NotFound in blazarclient + if isinstance(exc, client_exception.BlazarClientException) \ + and exc.kwargs['code'] == 404: + return True + return False def has_host(self): return True if self.client().host.list() else False + + def create_lease(self, **args): + return self.client().lease.create(**args) + + def get_lease(self, id): + return self.client().lease.get(id) + + def create_host(self, **args): + return self.client().host.create(**args) + + def get_host(self, id): + return self.client().host.get(id) diff --git a/heat/engine/resources/openstack/blazar/__init__.py b/heat/engine/resources/openstack/blazar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/engine/resources/openstack/blazar/lease.py b/heat/engine/resources/openstack/blazar/lease.py new file mode 100644 index 0000000000..4b70180af8 --- /dev/null +++ b/heat/engine/resources/openstack/blazar/lease.py @@ -0,0 +1,309 @@ +# +# 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 heat.common import exception +from heat.common.i18n import _ +from heat.engine import attributes +from heat.engine import constraints +from heat.engine import properties +from heat.engine import resource +from heat.engine import support + + +class Lease(resource.Resource): + """A resource to manage Blazar leases. + + Lease resource manages the reservations of specific type/amount of + cloud resources within OpenStack. + + Note: + Based on an agreement with Blazar team, this resource class does not + support updating, because current Blazar lease scheme is not suitable for + Heat, if you want to update a lease, you need to specify reservation's id, + which is one of attribute of lease. + """ + + support_status = support.SupportStatus(version='12.0.0') + + PROPERTIES = ( + NAME, START_DATE, END_DATE, BEFORE_END_DATE, + RESERVATIONS, RESOURCE_TYPE, MIN, MAX, + HYPERVISOR_PROPERTIES, RESOURCE_PROPERTIES, BEFORE_END, + AMOUNT, VCPUS, MEMORY_MB, DISK_GB, AFFINITY, EVENTS, + EVENT_TYPE, TIME, + ) = ( + 'name', 'start_date', 'end_date', 'before_end_date', + 'reservations', 'resource_type', 'min', 'max', + 'hypervisor_properties', 'resource_properties', 'before_end', + 'amount', 'vcpus', 'memory_mb', 'disk_gb', 'affinity', 'events', + 'event_type', 'time', + ) + + ATTRIBUTES = ( + NAME_ATTR, START_DATE_ATTR, END_DATE_ATTR, CREATED_AT_ATTR, + UPDATED_AT_ATTR, STATUS_ATTR, DEGRADED_ATTR, USER_ID_ATTR, + PROJECT_ID_ATTR, TRUST_ID_ATTR, RESERVATIONS_ATTR, EVENTS_ATTR, + ) = ( + 'name', 'start_date', 'end_date', 'created_at', + 'updated_at', 'status', 'degraded', 'user_id', + 'project_id', 'trust_id', 'reservations', 'events', + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('The name of the lease.'), + required=True, + ), + START_DATE: properties.Schema( + properties.Schema.STRING, + _('The start date and time of the lease. ' + 'The date and time format must be "CCYY-MM-DD hh:mm".'), + required=True, + constraints=[ + constraints.AllowedPattern(r'\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}'), + ], + ), + END_DATE: properties.Schema( + properties.Schema.STRING, + _('The end date and time of the lease ' + 'The date and time format must be "CCYY-MM-DD hh:mm".'), + required=True, + constraints=[ + constraints.AllowedPattern(r'\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}'), + ], + ), + BEFORE_END_DATE: properties.Schema( + properties.Schema.STRING, + _('The date and time for the before-end-action of the lease. ' + 'The date and time format must be "CCYY-MM-DD hh:mm".'), + constraints=[ + constraints.AllowedPattern(r'\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}'), + ], + ), + RESERVATIONS: properties.Schema( + properties.Schema.LIST, + _('The list of reservations.'), + required=True, + schema=properties.Schema( + properties.Schema.MAP, + schema={ + RESOURCE_TYPE: properties.Schema( + properties.Schema.STRING, + _('The type of the resource to reserve.'), + required=True, + constraints=[ + constraints.AllowedValues(['virtual:instance', + 'physical:host']) + ] + ), + MIN: properties.Schema( + properties.Schema.INTEGER, + _('The minimum number of hosts to reserve.'), + constraints=[ + constraints.Range(min=1) + ], + ), + MAX: properties.Schema( + properties.Schema.INTEGER, + _('The maximum number of hosts to reserve.'), + constraints=[ + constraints.Range(min=1) + ], + ), + HYPERVISOR_PROPERTIES: properties.Schema( + properties.Schema.STRING, + _('Properties of the hypervisor to reserve.'), + ), + RESOURCE_PROPERTIES: properties.Schema( + properties.Schema.STRING, + _('Properties of the resource to reserve.'), + ), + BEFORE_END: properties.Schema( + properties.Schema.STRING, + _('The before-end-action of the reservation.'), + default="default", + constraints=[ + constraints.AllowedValues(['default', + 'snapshot']) + ] + ), + AMOUNT: properties.Schema( + properties.Schema.INTEGER, + _('The amount of instances to reserve.'), + constraints=[ + constraints.Range(min=0, max=2147483647) + ], + ), + + VCPUS: properties.Schema( + properties.Schema.INTEGER, + _('The number of VCPUs per the instance.'), + constraints=[ + constraints.Range(min=0, max=2147483647) + ], + ), + MEMORY_MB: properties.Schema( + properties.Schema.INTEGER, + _('Megabytes of memory per the instance.'), + constraints=[ + constraints.Range(min=0, max=2147483647) + ], + ), + DISK_GB: properties.Schema( + properties.Schema.INTEGER, + _('Gigabytes of the local disk per the instance.'), + constraints=[ + constraints.Range(min=0, max=2147483647) + ], + ), + AFFINITY: properties.Schema( + properties.Schema.BOOLEAN, + _('The affinity of instances to reserve.'), + default=False, + ), + }, + ), + ), + EVENTS: properties.Schema( + properties.Schema.LIST, + _('A list of event objects.'), + default=[], + schema=properties.Schema( + properties.Schema.MAP, + schema={ + EVENT_TYPE: properties.Schema( + properties.Schema.STRING, + _('The type of the event (e.g. notification).'), + required=True, + ), + TIME: properties.Schema( + properties.Schema.STRING, + _('The date and time of the event. ' + 'The date and time format must be ' + '"CCYY-MM-DD hh:mm".'), + required=True, + ), + }, + ), + ), + + } + + attributes_schema = { + NAME_ATTR: attributes.Schema( + _('The name of the lease.'), + type=attributes.Schema.STRING + ), + START_DATE_ATTR: attributes.Schema( + _('The start date and time of the lease. ' + 'The date and time format is "CCYY-MM-DD hh:mm".'), + type=attributes.Schema.STRING + ), + END_DATE_ATTR: attributes.Schema( + _('The end date and time of the lease. ' + 'The date and time format is "CCYY-MM-DD hh:mm".'), + type=attributes.Schema.STRING + ), + CREATED_AT_ATTR: attributes.Schema( + _('The date and time when the lease was created. ' + 'The date and time format is "CCYY-MM-DD hh:mm".'), + type=attributes.Schema.STRING + ), + UPDATED_AT_ATTR: attributes.Schema( + _('The date and time when the lease was updated. ' + 'The date and time format is "CCYY-MM-DD hh:mm".'), + type=attributes.Schema.STRING + ), + STATUS_ATTR: attributes.Schema( + _('The status of the lease.'), + type=attributes.Schema.STRING + ), + DEGRADED_ATTR: attributes.Schema( + _('The flag which represents condition of reserved resources of ' + 'the lease. If it is true, the amount of reserved resources is ' + 'less than the request or reserved resources were changed.'), + type=attributes.Schema.BOOLEAN + ), + USER_ID_ATTR: attributes.Schema( + _('The UUID of the lease owner.'), + type=attributes.Schema.STRING + ), + PROJECT_ID_ATTR: attributes.Schema( + _('The UUID the project which owns the lease.'), + type=attributes.Schema.STRING + ), + TRUST_ID_ATTR: attributes.Schema( + _('The UUID of the trust of the lease owner.'), + type=attributes.Schema.STRING + ), + RESERVATIONS_ATTR: attributes.Schema( + _('A list of reservation objects.'), + type=attributes.Schema.LIST + ), + EVENTS_ATTR: attributes.Schema( + _('Event information of the lease.'), + type=attributes.Schema.LIST + ), + } + + default_client_name = 'blazar' + + entity = 'lease' + + def validate(self): + super(Lease, self).validate() + if not self.client_plugin().has_host(): + msg = ("Couldn't find any host in Blazar. " + "You must create a host before creating a lease.") + raise exception.StackValidationFailed(message=msg) + + def _parse_reservation(self, rsv): + if rsv['resource_type'] == "physical:host": + for key in ['vcpus', 'memory_mb', 'disk_gb', 'affinity', 'amount']: + rsv.pop(key) + elif rsv['resource_type'] == "virtual:instance": + for key in ['hypervisor_properties', 'max', 'min', 'before_end']: + rsv.pop(key) + + return rsv + + def handle_create(self): + args = dict((k, v) for k, v in self.properties.items() + if v is not None) + # rename keys + args['start'] = args.pop('start_date') + args['end'] = args.pop('end_date') + + # parse reservations + args['reservations'] = [self._parse_reservation(rsv) + for rsv in args['reservations']] + lease = self.client_plugin().create_lease(**args) + self.resource_id_set(lease['id']) + return lease['id'] + + def _resolve_attribute(self, name): + if self.resource_id is None: + return + lease = self.client_plugin().get_lease(self.resource_id) + try: + return lease[name] + except KeyError: + raise exception.InvalidTemplateAttribute(resource=self.name, + key=name) + + +def resource_mapping(): + return { + 'OS::Blazar::Lease': Lease + } diff --git a/heat/tests/openstack/blazar/__init__.py b/heat/tests/openstack/blazar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/tests/openstack/blazar/test_lease.py b/heat/tests/openstack/blazar/test_lease.py new file mode 100644 index 0000000000..4991a14d21 --- /dev/null +++ b/heat/tests/openstack/blazar/test_lease.py @@ -0,0 +1,223 @@ +# +# 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 blazarclient import exception as client_exception +import mock +from oslo_utils.fixture import uuidsentinel as uuids + +from heat.common import exception +from heat.common import template_format +from heat.engine.clients.os import blazar +from heat.engine.resources.openstack.blazar import lease +from heat.engine import scheduler +from heat.tests import common +from heat.tests import utils + + +blazar_lease_host_template = ''' +heat_template_version: rocky + +resources: + test-lease: + type: OS::Blazar::Lease + properties: + name: test-lease + start_date: '2020-01-01 09:00' + end_date: '2020-01-10 17:30' + reservations: + - resource_type: 'physical:host' + min: 1 + max: 1 + hypervisor_properties: '[">=", "$vcpus", "2"]' + resource_properties: '' + before_end: 'default' +''' + +blazar_lease_instance_template = ''' +heat_template_version: rocky + +resources: + test-lease: + type: OS::Blazar::Lease + properties: + name: test-lease + start_date: '2020-01-01 09:00' + end_date: '2020-01-10 17:30' + reservations: + - resource_type: 'virtual:instance' + amount: 1 + vcpus: 1 + memory_mb: 512 + disk_gb: 15 + affinity: false + resource_properties: '' +''' + + +class BlazarLeaseTestCase(common.HeatTestCase): + + def setUp(self): + super(BlazarLeaseTestCase, self).setUp() + + self.lease = { + "id": uuids.lease_id, + "name": "test-lease", + "start_date": "2020-01-01 09:00", + "end_date": "2020-01-10 17:30", + "created_at": "2020-01-01 08:00", + "updated_at": "2020-01-01 12:00", + "degraded": False, + "user_id": uuids.user_id, + "project_id": uuids.project_id, + "trust_id": uuids.trust_id, + "reservations": [ + { + "resource_type": "physical:host", + "min": 1, + "max": 1, + "hypervisor_properties": "[\">=\", \"$vcpus\", \"2\"]", + "resource_properties": "", + "before_end": "default" + }, + ], + "events": [] + } + + t = template_format.parse(blazar_lease_host_template) + self.stack = utils.parse_stack(t) + resource_defns = self.stack.t.resource_definitions(self.stack) + self.rsrc_defn = resource_defns['test-lease'] + self.client = mock.Mock() + self.patchobject(blazar.BlazarClientPlugin, 'client', + return_value=self.client) + + def _create_resource(self, name, snippet, stack): + self.client.lease.create.return_value = self.lease + return lease.Lease(name, snippet, stack) + + def test_lease_host_create(self): + self.patchobject(blazar.BlazarClientPlugin, 'client', + return_value=self.client) + self.client.has_host.return_value = True + lease_resource = self._create_resource('lease', self.rsrc_defn, + self.stack) + self.assertEqual(self.lease['name'], + lease_resource.properties.get(lease.Lease.NAME)) + + self.assertIsNone(lease_resource.validate()) + + scheduler.TaskRunner(lease_resource.create)() + self.assertEqual(uuids.lease_id, + lease_resource.resource_id) + self.assertEqual((lease_resource.CREATE, lease_resource.COMPLETE), + lease_resource.state) + self.assertEqual('lease', lease_resource.entity) + self.client.lease.create.assert_called_once_with( + name=self.lease['name'], start=self.lease['start_date'], + end=self.lease['end_date'], + reservations=self.lease['reservations'], + events=self.lease['events']) + + def test_lease_host_create_validate_fail(self): + self.patchobject(lease.Lease, 'client_plugin', + return_value=self.client) + self.client.has_host.return_value = False + lease_resource = self._create_resource('lease', self.rsrc_defn, + self.stack) + self.assertEqual(self.lease['name'], + lease_resource.properties.get(lease.Lease.NAME)) + + self.assertRaises(exception.StackValidationFailed, + lease_resource.validate) + + def test_lease_instance_create(self): + t = template_format.parse(blazar_lease_instance_template) + stack = utils.parse_stack(t) + resource_defn = stack.t.resource_definitions(stack) + rsrc_defn = resource_defn['test-lease'] + + lease_resource = self._create_resource('lease', rsrc_defn, stack) + + self.assertEqual(self.lease['name'], + lease_resource.properties.get(lease.Lease.NAME)) + + scheduler.TaskRunner(lease_resource.create)() + self.assertEqual(uuids.lease_id, + lease_resource.resource_id) + self.assertEqual((lease_resource.CREATE, + lease_resource.COMPLETE), lease_resource.state) + self.assertEqual('lease', lease_resource.entity) + + reservations = [ + { + 'resource_type': 'virtual:instance', + 'amount': 1, + 'vcpus': 1, + 'memory_mb': 512, + 'disk_gb': 15, + 'affinity': False, + 'resource_properties': '' + } + ] + + self.client.lease.create.assert_called_once_with( + name=self.lease['name'], start=self.lease['start_date'], + end=self.lease['end_date'], + reservations=reservations, + events=self.lease['events']) + + def test_lease_delete(self): + lease_resource = self._create_resource('lease', self.rsrc_defn, + self.stack) + self.client.lease.delete.return_value = None + + scheduler.TaskRunner(lease_resource.create)() + self.client.lease.get.side_effect = [ + 'lease_obj', client_exception.BlazarClientException(code=404)] + scheduler.TaskRunner(lease_resource.delete)() + self.assertEqual((lease_resource.DELETE, lease_resource.COMPLETE), + lease_resource.state) + self.assertEqual(1, self.client.lease.delete.call_count) + + def test_lease_delete_not_found(self): + lease_resource = self._create_resource('lease', self.rsrc_defn, + self.stack) + + scheduler.TaskRunner(lease_resource.create)() + self.client.lease.delete.side_effect = client_exception.\ + BlazarClientException(code=404) + self.client.lease.get.side_effect = client_exception.\ + BlazarClientException(code=404) + scheduler.TaskRunner(lease_resource.delete)() + self.assertEqual((lease_resource.DELETE, lease_resource.COMPLETE), + lease_resource.state) + + def test_resolve_attributes(self): + lease_resource = self._create_resource('lease', self.rsrc_defn, + self.stack) + + scheduler.TaskRunner(lease_resource.create)() + self.client.lease.get.return_value = self.lease + self.assertEqual(self.lease['start_date'], + lease_resource._resolve_attribute + (lease.Lease.START_DATE)) + + def test_resolve_attributes_not_found(self): + lease_resource = self._create_resource('lease', self.rsrc_defn, + self.stack) + + scheduler.TaskRunner(lease_resource.create)() + self.client.lease.get.return_value = self.lease + self.assertRaises(exception.InvalidTemplateAttribute, + lease_resource._resolve_attribute, + "invalid_attribute") diff --git a/lower-constraints.txt b/lower-constraints.txt index cd1b50d113..4e60e9fb6c 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -75,7 +75,7 @@ oslo.reports==1.18.0 oslo.serialization==2.18.0 oslo.service==1.24.0 oslo.upgradecheck==0.1.0 -oslo.utils==3.33.0 +oslo.utils==3.37.0 oslo.versionedobjects==1.31.2 oslotest==3.2.0 osprofiler==1.4.0 diff --git a/releasenotes/notes/add-blazar-lease-resource-724caa6572e44182.yaml b/releasenotes/notes/add-blazar-lease-resource-724caa6572e44182.yaml new file mode 100644 index 0000000000..af168678d0 --- /dev/null +++ b/releasenotes/notes/add-blazar-lease-resource-724caa6572e44182.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + A new ``OS::Blazar::Lease`` resource is added to manage reservations for + specific type/amount of cloud resources in OpenStack. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b608910d6a..ca1a089ebd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ oslo.reports>=1.18.0 # Apache-2.0 oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0 oslo.service!=1.28.1,>=1.24.0 # Apache-2.0 oslo.upgradecheck>=0.1.0 # Apache-2.0 -oslo.utils>=3.33.0 # Apache-2.0 +oslo.utils>=3.37.0 # Apache-2.0 osprofiler>=1.4.0 # Apache-2.0 oslo.versionedobjects>=1.31.2 # Apache-2.0 PasteDeploy>=1.5.0 # MIT