Reservation extensions to Nova API

* ReservationController: sends lease creation request to Climate
  if instance should be reserved.
* DefaultReservationController: adds hints to server creation request
  if every VM should be reserved for some amount of time.

Implements: blueprint nova-api-extensions

Change-Id: I4aee23c5d8c76ee6f1cb0fee1bc54e784a83345d
This commit is contained in:
Dina Belova 2013-12-19 16:14:31 +04:00
parent 66410039b7
commit 7a6bca02c3
11 changed files with 592 additions and 2 deletions

View File

@ -1,4 +1,6 @@
climate-nova
============
Climate Nova filter
Climate Nova related changes. Includes filters for Host
Reservation and Nova API extensions for the VM reservation
feature.

View File

View File

View File

@ -0,0 +1,120 @@
# Copyright (c) 2013 Mirantis Inc.
#
# 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.
"""
Default reservation extension may be used if there is need to reserve all
VMs by default (like in the developer clouds: every VM may be not just booted,
but reserved for some amount of time, after which VM should be deleted or
suspended or whatever).
"""
import datetime
import json
from nova.api.openstack import extensions
from nova.api.openstack import wsgi
from nova.openstack.common import log as logging
from nova import utils
from oslo.config import cfg
from webob import exc
reservation_opts = [
cfg.StrOpt('reservation_start_date',
default='now',
help='Specify date for all leases to be started for every VM'),
cfg.IntOpt('reservation_length_days',
default=30,
help='Number of days for VM to be reserved.'),
cfg.IntOpt('reservation_length_hours',
default=0,
help='Number of hours for VM to be reserved.'),
cfg.IntOpt('reservation_length_minutes',
default=0,
help='Number of minutes for VM to be reserved.')
]
CONF = cfg.CONF
CONF.register_opts(reservation_opts)
LOG = logging.getLogger(__name__)
authorize = extensions.extension_authorizer('compute', 'default_reservation')
class DefaultReservationController(wsgi.Controller):
"""Add default reservation flags to every VM started."""
@wsgi.extends
def create(self, req, body):
"""Add additional hints to the create server request.
They will be managed by Reservation Extension.
"""
if not self.is_valid_body(body, 'server'):
raise exc.HTTPUnprocessableEntity()
if 'server' in body:
scheduler_hints = body['server'].get('scheduler_hints', {})
elif 'os:scheduler_hints' in body:
scheduler_hints = body['os:scheduler_hints']
else:
scheduler_hints = body.get('OS-SCH-HNT:scheduler_hints', {})
lease_params = json.loads(scheduler_hints.get('lease_params', '{}'))
delta_days = datetime.timedelta(days=CONF.reservation_length_days)
delta_hours = datetime.timedelta(hours=CONF.reservation_length_hours)
delta_minutes = datetime.timedelta(
minutes=CONF.reservation_length_minutes
)
if CONF.reservation_start_date == 'now':
base = datetime.datetime.utcnow()
else:
base = datetime.datetime.strptime(CONF.reservation_start_date,
"%Y-%m-%d %H:%M")
lease_params.setdefault('name', utils.generate_uid('lease', size=6))
lease_params.setdefault('start', CONF.reservation_start_date)
lease_params.setdefault(
'end', (base + delta_days + delta_hours + delta_minutes).
strftime('%Y-%m-%d %H:%M'))
default_hints = {'lease_params': json.dumps(lease_params)}
if 'server' in body:
if 'scheduler_hints' in body['server']:
body['server']['scheduler_hints'].update(default_hints)
else:
body['server']['scheduler_hints'] = default_hints
else:
attr = 'OS-SCH-HNT:scheduler_hints'
if 'os:scheduler_hints' in body:
body['os:scheduler_hints'].update(default_hints)
elif attr in body and 'lease_params' not in body[attr]:
body[attr].update(default_hints)
yield
class Default_reservation(extensions.ExtensionDescriptor):
"""Instance reservation system."""
name = "DefaultReservation"
alias = "os-default-instance-reservation"
def get_controller_extensions(self):
controller = DefaultReservationController()
extension = extensions.ControllerExtension(self, 'servers', controller)
return [extension]

View File

@ -0,0 +1,184 @@
# Copyright (c) 2013 Mirantis Inc.
#
# 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.
"""
Reservation extension parses instance creation request to find hints referring
to use Climate. If finds, create instance, shelve it not to use compute
capacity while instance is not used and sent lease creation request to Climate.
"""
import json
import time
import traceback
try:
from climateclient import client as climate_client
except ImportError:
climate_client = None
from nova.api.openstack import extensions
from nova.api.openstack import wsgi
from nova import compute
from nova import exception
from nova.openstack.common.gettextutils import _ # noqa
from nova.openstack.common import log as logging
LOG = logging.getLogger(__name__)
authorize = extensions.extension_authorizer('compute', 'reservation')
class ReservationController(wsgi.Controller):
"""Reservation controller to support VMs wake up."""
def __init__(self, *args, **kwargs):
super(ReservationController, self).__init__(*args, **kwargs)
self.compute_api = compute.API()
@wsgi.extends
def create(self, req, resp_obj, body):
"""Support Climate usage for Nova VMs."""
scheduler_hints = body.get('server', {}).get('scheduler_hints', {})
lease_params = scheduler_hints.get('lease_params')
if lease_params:
try:
lease_params = json.loads(lease_params)
except ValueError:
raise ValueError(_('Wrong JSON format for lease parameters '
'passed %s') % lease_params)
if 'events' not in lease_params:
lease_params.update({'events': []})
instance_id = resp_obj.obj['server']['id']
lease_params.update({
'reservations': [{'resource_id': instance_id,
'resource_type': 'virtual:instance'}],
})
service_catalog = json.loads(req.environ['HTTP_X_SERVICE_CATALOG'])
user_roles = req.environ['HTTP_X_ROLES'].split(',')
auth_token = req.environ['HTTP_X_AUTH_TOKEN']
nova_ctx = req.environ["nova.context"]
instance = self.compute_api.get(nova_ctx, instance_id,
want_objects=True)
lease_transaction = LeaseTransaction()
with lease_transaction:
while instance.vm_state != 'active':
if instance.vm_state == 'error':
raise exception.InstanceNotRunning(
_("Instance %s responded with error state") %
instance_id
)
instance = self.compute_api.get(nova_ctx, instance_id,
want_objects=True)
time.sleep(1)
self.compute_api.shelve(nova_ctx, instance)
# shelve instance
while instance.vm_state != 'shelved_offloaded':
instance = self.compute_api.get(nova_ctx, instance_id,
want_objects=True)
time.sleep(1)
# send lease creation request to Climate
# this operation should be last, because otherwise Climate
# Manager may try unshelve instance when it's still active
climate_cl = self.get_climate_client(service_catalog,
user_roles, auth_token)
lease_transaction.set_params(instance_id, climate_cl, nova_ctx)
lease = climate_cl.lease.create(**lease_params)
try:
lease_transaction.set_lease_id(lease['id'])
except Exception:
raise exception.InternalError(
_('Lease creation request failed.')
)
def get_climate_client(self, catalog, user_roles, auth_token):
if not climate_client:
raise ImportError(_('No Climate client installed to the '
'environment. Please install it to use '
'reservation for Nova instances.'))
climate_endpoints = None
for service in catalog:
if service['type'] == 'reservation':
climate_endpoints = service['endpoints'][0]
if not climate_endpoints:
raise exception.NotFound(_('No Climate endpoint found in service '
'catalog.'))
climate_url = None
if 'admin' in user_roles:
climate_url = climate_endpoints.get('adminURL')
if climate_url is None:
climate_url = climate_endpoints.get('publicURL')
if climate_url is None:
raise exception.NotFound(_('No Climate URL found in service '
'catalog.'))
climate_cl = climate_client.Client(climate_url=climate_url,
auth_token=auth_token)
return climate_cl
class LeaseTransaction(object):
def __init__(self):
self.lease_id = None
self.instance_id = None
self.climate_cl = None
self.nova_ctx = None
def __enter__(self):
pass
def __exit__(self, exc_type, exc_value, exc_traceback):
if exc_type and self.instance_id:
msg = '\n'.join(traceback.format_exception(
exc_type, exc_value, exc_traceback))
LOG.error(_('Error occurred while lease creation. '
'Traceback: \n%s') % msg)
if self.lease_id:
self.climate_cl.lease.delete(self.lease_id)
api = compute.API()
api.delete(self.nova_ctx, api.get(self.nova_ctx, self.instance_id,
want_objects=True))
def set_lease_id(self, lease_id):
self.lease_id = lease_id
def set_params(self, instance_id, climate_cl, nova_ctx):
self.instance_id = instance_id
self.climate_cl = climate_cl
self.nova_ctx = nova_ctx
class Reservation(extensions.ExtensionDescriptor):
"""Instance reservation system."""
name = "Reservation"
alias = "os-instance-reservation"
def get_controller_extensions(self):
controller = ReservationController()
extension = extensions.ControllerExtension(self, 'servers', controller)
return [extension]

View File

View File

@ -0,0 +1,110 @@
# Copyright (c) 2013 Mirantis Inc.
#
# 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
from nova.tests.api.openstack import fakes
import mock
from nova.api.openstack import compute
from nova.compute import api as compute_api
from nova import context
from nova import test
from nova import utils
from oslo.config import cfg
UUID = fakes.FAKE_UUID
CONF = cfg.CONF
CONF.import_opt('osapi_compute_ext_list', 'nova.api.openstack.compute.contrib')
CONF.import_opt('reservation_start_date',
'climatenova.api.extensions.default_reservation')
CONF.import_opt('reservation_length_hours',
'climatenova.api.extensions.default_reservation')
CONF.import_opt('reservation_length_days',
'climatenova.api.extensions.default_reservation')
CONF.import_opt('reservation_length_minutes',
'climatenova.api.extensions.default_reservation')
class InstanceWrapper(object):
# this wrapper is needed to make dict look like object with fields and
# dictionary at the same time
def __init__(self, dct):
self._dct = dct
def __getattr__(self, item):
return self._dct.get(item)
__getitem__ = __getattr__
class BaseExtensionTestCase(test.TestCase):
def setUp(self):
"""Set up testing environment."""
super(BaseExtensionTestCase, self).setUp()
self.fake_instance = fakes.stub_instance(1, uuid=UUID)
self.flags(
osapi_compute_extension=[
'nova.api.openstack.compute.contrib.select_extensions',
'climatenova.api.extensions.default_reservation.'
'Default_reservation',
'climatenova.api.extensions.reservation.Reservation'
],
osapi_compute_ext_list=['Scheduler_hints'])
self.lease_controller = mock.MagicMock()
self.lease_controller.create = mock.MagicMock()
self.mock_client = mock.MagicMock()
self.mock_client.lease = self.lease_controller
self.req = fakes.HTTPRequest.blank('/fake/servers')
self.req.method = 'POST'
self.req.content_type = 'application/json'
self.req.environ.update({
'HTTP_X_SERVICE_CATALOG': '[{"type": "reservation", '
'"endpoints": [{"publicURL": "fake"}]}]',
'HTTP_X_ROLES': '12,34',
'HTTP_X_AUTH_TOKEN': 'fake_token',
'nova.context': context.RequestContext('fake', 'fake'),
})
self.l_name = 'lease_123'
self.app = compute.APIRouter(init_only=('servers',))
self.stubs.Set(utils, 'generate_uid', lambda name, size: self.l_name)
self.stubs.Set(compute_api.API, 'create', self._fake_create)
self.stubs.Set(compute_api.API, 'get', self._fake_get)
self.stubs.Set(compute_api.API, 'shelve', self._fake_shelve)
delta_days = datetime.timedelta(days=CONF.reservation_length_days)
delta_hours = datetime.timedelta(hours=CONF.reservation_length_hours)
delta_minutes = datetime.timedelta(
minutes=CONF.reservation_length_minutes)
self.delta = delta_days + delta_hours + delta_minutes
lease_end = datetime.datetime.utcnow() + self.delta
self.default_lease_end = lease_end.strftime('%Y-%m-%d %H:%M')
self.default_lease_start = 'now'
def _fake_create(self, *args, **kwargs):
return [self.fake_instance], ''
def _fake_get(self, *args, **kwargs):
self.fake_instance['vm_state'] = 'active'
return InstanceWrapper(self.fake_instance)
def _fake_shelve(self, *args):
self.fake_instance['vm_state'] = 'shelved_offloaded'

View File

@ -0,0 +1,98 @@
# Copyright (c) 2013 Mirantis Inc.
#
# 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 mock
from nova.openstack.common import jsonutils
from climatenova.tests.api import extensions
class ClimateDefaultReservationTestCase(extensions.BaseExtensionTestCase):
"""Climate API extensions test case.
This test case provides tests for Default_reservation extension working
together with Reservation extension passing hints to Nova and
sending lease creation request to Climate.
"""
def setUp(self):
"""Set up testing environment."""
super(ClimateDefaultReservationTestCase, self).setUp()
@mock.patch('climatenova.api.extensions.reservation.climate_client')
def test_create_with_default(self, mock_module):
"""Test extension work with default lease parameters."""
mock_module.Client.return_value = self.mock_client
# here we set no Climate related hints, so all lease info should be
# default one - dates got from CONF and auto generated name
body = {
'server': {
'name': 'server_test',
'imageRef': 'cedef40a-ed67-4d10-800e-17455edce175',
'flavorRef': '1',
}
}
self.req.body = jsonutils.dumps(body)
res = self.req.get_response(self.app)
mock_module.Client.assert_called_once_with(climate_url='fake',
auth_token='fake_token')
self.lease_controller.create.assert_called_once_with(
reservations=[
{'resource_type': 'virtual:instance',
'resource_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}],
end=self.default_lease_end,
events=[],
start='now',
name='lease_123')
self.assertEqual(202, res.status_int)
@mock.patch('climatenova.api.extensions.reservation.climate_client')
def test_create_with_passed_args(self, mock_module):
"""Test extension work if some lease param would be passed."""
# here we pass non default lease name to Nova
# Default_reservation extension should not rewrite it,
# Reservation extension should pass it to Climate
mock_module.Client.return_value = self.mock_client
body = {
'server': {
'name': 'server_test',
'imageRef': 'cedef40a-ed67-4d10-800e-17455edce175',
'flavorRef': '1',
},
'os:scheduler_hints': {'lease_params': '{"name": "other_name"}'},
}
self.req.body = jsonutils.dumps(body)
res = self.req.get_response(self.app)
mock_module.Client.assert_called_once_with(climate_url='fake',
auth_token='fake_token')
self.lease_controller.create.assert_called_once_with(
reservations=[
{'resource_type': 'virtual:instance',
'resource_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}],
end=self.default_lease_end,
events=[],
start='now',
name='other_name')
self.assertEqual(202, res.status_int)

View File

@ -0,0 +1,72 @@
# Copyright (c) 2013 Mirantis Inc.
#
# 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 mock
from nova.openstack.common import jsonutils
from climatenova.tests.api import extensions
class ClimateReservationTestCase(extensions.BaseExtensionTestCase):
"""Climate API extensions test case.
This test case provides tests for Default_reservation extension working
together with Reservation extension passing hints to Nova and
sending lease creation request to Climate.
"""
def setUp(self):
"""Set up testing environment."""
super(ClimateReservationTestCase, self).setUp()
self.flags(
osapi_compute_extension=[
'nova.api.openstack.compute.contrib.select_extensions',
'climatenova.api.extensions.reservation.Reservation'
],
osapi_compute_ext_list=['Scheduler_hints'])
@mock.patch('climatenova.api.extensions.reservation.climate_client')
def test_create(self, mock_module):
"""Test extension work with passed lease parameters."""
mock_module.Client.return_value = self.mock_client
body = {
'server': {
'name': 'server_test',
'imageRef': 'cedef40a-ed67-4d10-800e-17455edce175',
'flavorRef': '1',
},
'os:scheduler_hints': {
'lease_params': '{"name": "some_name", '
'"start": "2014-02-09 12:00", '
'"end": "2014-02-10 12:00"}'}
}
self.req.body = jsonutils.dumps(body)
res = self.req.get_response(self.app)
mock_module.Client.assert_called_once_with(climate_url='fake',
auth_token='fake_token')
self.lease_controller.create.assert_called_once_with(
reservations=[
{'resource_type': 'virtual:instance',
'resource_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}],
end='2014-02-10 12:00',
events=[],
start='2014-02-09 12:00',
name='some_name')
self.assertEqual(202, res.status_int)

View File

@ -2,3 +2,7 @@
scheduler_available_filters = nova.scheduler.filters.all_filters
scheduler_available_filters = climatenova.scheduler.filters.climate_filter.ClimateFilter
scheduler_default_filters=RetryFilter,AvailabilityZoneFilter,RamFilter,ComputeFilter,ComputeCapabilitiesFilter,ImagePropertiesFilter,ClimateFilter
osapi_compute_extension = nova.api.openstack.compute.contrib.standard_extensions
osapi_compute_extension = climatenova.api.extensions.default_reservation.Default_reservation
osapi_compute_extension = climatenova.api.extensions.reservation.Reservation

View File

@ -10,6 +10,6 @@ sphinxcontrib-httpdomain
discover
fixtures>=0.3.14
testrepository>=0.0.17
testtools>=0.9.32
testtools>=0.9.34
http://tarballs.openstack.org/nova/nova-master.tar.gz#egg=nova