1#   Copyright 2014 IBM Corp.
2#   Copyright (c) 2016 Stratoscale, Ltd.
3#
4#   Licensed under the Apache License, Version 2.0 (the "License"); you may
5#   not use this file except in compliance with the License. You may obtain
6#   a copy of the License at
7#
8#       http://www.apache.org/licenses/LICENSE-2.0
9#
10#   Unless required by applicable law or agreed to in writing, software
11#   distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12#   WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13#   License for the specific language governing permissions and limitations
14#   under the License.
15
16import ddt
17import mock
18from oslo_config import cfg
19import oslo_messaging as messaging
20from oslo_serialization import jsonutils
21from six.moves import http_client
22from six.moves.urllib.parse import urlencode
23import webob
24
25from cinder.api.contrib import volume_manage
26from cinder.api import microversions as mv
27from cinder.api.openstack import api_version_request as api_version
28from cinder import context
29from cinder import exception
30from cinder.objects import fields
31from cinder import test
32from cinder.tests.unit.api import fakes
33from cinder.tests.unit import fake_constants as fake
34from cinder.tests.unit import fake_volume
35
36CONF = cfg.CONF
37
38
39def app():
40    # no auth, just let environ['cinder.context'] pass through
41    api = fakes.router.APIRouter()
42    mapper = fakes.urlmap.URLMap()
43    mapper['/v2'] = api
44    return mapper
45
46
47def app_v3():
48    # no auth, just let environ['cinder.context'] pass through
49    api = fakes.router.APIRouter()
50    mapper = fakes.urlmap.URLMap()
51    mapper['/v3'] = api
52    return mapper
53
54
55def service_get(context, service_id, backend_match_level=None, host=None,
56                **filters):
57    """Replacement for db.sqlalchemy.api.service_get.
58
59    We mock the db.sqlalchemy.api.service_get method to return something for a
60    specific host, and raise an exception for anything else.
61    We don't use the returned data (the code under test just use the call to
62    check for existence of a host, so the content returned doesn't matter.
63    """
64    if host == 'host_ok':
65        return {'disabled': False,
66                'uuid': 'a3a593da-7f8d-4bb7-8b4c-f2bc1e0b4824'}
67    if host == 'host_disabled':
68        return {'disabled': True,
69                'uuid': '4200b32b-0bf9-436c-86b2-0675f6ac218e'}
70    raise exception.ServiceNotFound(service_id=host)
71
72# Some of the tests check that volume types are correctly validated during a
73# volume manage operation.  This data structure represents an existing volume
74# type.
75fake_vt = {'id': fake.VOLUME_TYPE_ID,
76           'name': 'good_fakevt'}
77
78
79def vt_get_volume_type_by_name(context, name):
80    """Replacement for cinder.volume.volume_types.get_volume_type_by_name.
81
82    Overrides cinder.volume.volume_types.get_volume_type_by_name to return
83    the volume type based on inspection of our fake structure, rather than
84    going to the Cinder DB.
85    """
86    if name == fake_vt['name']:
87        return fake_vt
88    raise exception.VolumeTypeNotFoundByName(volume_type_name=name)
89
90
91def vt_get_volume_type(context, vt_id):
92    """Replacement for cinder.volume.volume_types.get_volume_type.
93
94    Overrides cinder.volume.volume_types.get_volume_type to return the
95    volume type based on inspection of our fake structure, rather than going
96    to the Cinder DB.
97    """
98    if vt_id == fake_vt['id']:
99        return fake_vt
100    raise exception.VolumeTypeNotFound(volume_type_id=vt_id)
101
102
103def api_manage(*args, **kwargs):
104    """Replacement for cinder.volume.api.API.manage_existing.
105
106    Overrides cinder.volume.api.API.manage_existing to return some fake volume
107    data structure, rather than initiating a real volume managing.
108
109    Note that we don't try to replicate any passed-in information (e.g. name,
110    volume type) in the returned structure.
111    """
112    ctx = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True)
113    vol = {
114        'status': 'creating',
115        'display_name': 'fake_name',
116        'availability_zone': 'nova',
117        'tenant_id': fake.PROJECT_ID,
118        'id': fake.VOLUME_ID,
119        'volume_type': None,
120        'snapshot_id': None,
121        'user_id': fake.USER_ID,
122        'size': 0,
123        'attach_status': fields.VolumeAttachStatus.DETACHED,
124        'volume_type_id': None}
125    return fake_volume.fake_volume_obj(ctx, **vol)
126
127
128def api_manage_new(*args, **kwargs):
129    volume = api_manage()
130    volume.status = 'managing'
131    return volume
132
133
134def api_get_manageable_volumes(*args, **kwargs):
135    """Replacement for cinder.volume.api.API.get_manageable_volumes."""
136    vols = [
137        {'reference': {'source-name': 'volume-%s' % fake.VOLUME_ID},
138         'size': 4,
139         'extra_info': 'qos_setting:high',
140         'safe_to_manage': False,
141         'cinder_id': fake.VOLUME_ID,
142         'reason_not_safe': 'volume in use'},
143        {'reference': {'source-name': 'myvol'},
144         'size': 5,
145         'extra_info': 'qos_setting:low',
146         'safe_to_manage': True,
147         'cinder_id': None,
148         'reason_not_safe': None}]
149    return vols
150
151
152@ddt.ddt
153@mock.patch('cinder.db.sqlalchemy.api.service_get', service_get)
154@mock.patch('cinder.volume.volume_types.get_volume_type_by_name',
155            vt_get_volume_type_by_name)
156@mock.patch('cinder.volume.volume_types.get_volume_type',
157            vt_get_volume_type)
158class VolumeManageTest(test.TestCase):
159    """Test cases for cinder/api/contrib/volume_manage.py
160
161    The API extension adds a POST /os-volume-manage API that is passed a cinder
162    host name, and a driver-specific reference parameter.  If everything
163    is passed correctly, then the cinder.volume.api.API.manage_existing method
164    is invoked to manage an existing storage object on the host.
165
166    In this set of test cases, we are ensuring that the code correctly parses
167    the request structure and raises the correct exceptions when things are not
168    right, and calls down into cinder.volume.api.API.manage_existing with the
169    correct arguments.
170    """
171
172    def setUp(self):
173        super(VolumeManageTest, self).setUp()
174        self._admin_ctxt = context.RequestContext(fake.USER_ID,
175                                                  fake.PROJECT_ID,
176                                                  is_admin=True)
177        self._non_admin_ctxt = context.RequestContext(fake.USER_ID,
178                                                      fake.PROJECT_ID,
179                                                      is_admin=False)
180        self.controller = volume_manage.VolumeManageController()
181
182    def _get_resp_post(self, body):
183        """Helper to execute a POST os-volume-manage API call."""
184        req = webob.Request.blank('/v2/%s/os-volume-manage' % fake.PROJECT_ID)
185        req.method = 'POST'
186        req.headers['Content-Type'] = 'application/json'
187        req.environ['cinder.context'] = self._admin_ctxt
188        req.body = jsonutils.dump_as_bytes(body)
189        res = req.get_response(app())
190        return res
191
192    def _get_resp_post_v3(self, body, version):
193        """Helper to execute a POST os-volume-manage API call."""
194        req = webob.Request.blank('/v3/%s/os-volume-manage' % fake.PROJECT_ID)
195        req.method = 'POST'
196        req.headers['Content-Type'] = 'application/json'
197        req.environ['cinder.context'] = self._admin_ctxt
198        req.headers["OpenStack-API-Version"] = "volume " + version
199        req.api_version_request = api_version.APIVersionRequest(version)
200        req.body = jsonutils.dump_as_bytes(body)
201        res = req.get_response(app_v3())
202        return res
203
204    @ddt.data(False, True)
205    @mock.patch('cinder.volume.api.API.manage_existing', wraps=api_manage)
206    @mock.patch(
207        'cinder.api.openstack.wsgi.Controller.validate_name_and_description')
208    def test_manage_volume_ok(self, cluster, mock_validate, mock_api_manage):
209        """Test successful manage volume execution.
210
211        Tests for correct operation when valid arguments are passed in the
212        request body.  We ensure that cinder.volume.api.API.manage_existing got
213        called with the correct arguments, and that we return the correct HTTP
214        code to the caller.
215        """
216        body = {'volume': {'host': 'host_ok',
217                           'ref': 'fake_ref'}}
218        # This will be ignored
219        if cluster:
220            body['volume']['cluster'] = 'cluster'
221        res = self._get_resp_post(body)
222        self.assertEqual(http_client.ACCEPTED, res.status_int)
223
224        # Check that the manage API was called with the correct arguments.
225        self.assertEqual(1, mock_api_manage.call_count)
226        args = mock_api_manage.call_args[0]
227        self.assertEqual(body['volume']['host'], args[1])
228        self.assertIsNone(args[2])  # Cluster argument
229        self.assertEqual(body['volume']['ref'], args[3])
230        self.assertTrue(mock_validate.called)
231
232    def _get_resp_create(self, body, version=mv.BASE_VERSION):
233        url = '/v3/%s/os-volume-manage' % fake.PROJECT_ID
234        req = webob.Request.blank(url, base_url='http://localhost.com' + url)
235        req.method = 'POST'
236        req.headers = mv.get_mv_header(version)
237        req.headers['Content-Type'] = 'application/json'
238        req.environ['cinder.context'] = self._admin_ctxt
239        req.body = jsonutils.dump_as_bytes(body)
240        req.api_version_request = mv.get_api_version(version)
241        res = self.controller.create(req, body)
242        return res
243
244    @mock.patch('cinder.volume.api.API.manage_existing', wraps=api_manage)
245    @mock.patch(
246        'cinder.api.openstack.wsgi.Controller.validate_name_and_description')
247    def test_manage_volume_ok_cluster(self, mock_validate, mock_api_manage):
248        body = {'volume': {'cluster': 'cluster',
249                           'ref': 'fake_ref'}}
250        res = self._get_resp_create(body, mv.VOLUME_MIGRATE_CLUSTER)
251        self.assertEqual(['volume'], list(res.keys()))
252
253        # Check that the manage API was called with the correct arguments.
254        self.assertEqual(1, mock_api_manage.call_count)
255        args = mock_api_manage.call_args[0]
256        self.assertIsNone(args[1])
257        self.assertEqual(body['volume']['cluster'], args[2])
258        self.assertEqual(body['volume']['ref'], args[3])
259        self.assertTrue(mock_validate.called)
260
261    @mock.patch(
262        'cinder.api.openstack.wsgi.Controller.validate_name_and_description')
263    def test_manage_volume_fail_host_cluster(self, mock_validate):
264        body = {'volume': {'host': 'host_ok',
265                           'cluster': 'cluster',
266                           'ref': 'fake_ref'}}
267        self.assertRaises(exception.InvalidInput,
268                          self._get_resp_create, body,
269                          mv.VOLUME_MIGRATE_CLUSTER)
270
271    def test_manage_volume_missing_host(self):
272        """Test correct failure when host is not specified."""
273        body = {'volume': {'ref': 'fake_ref'}}
274        res = self._get_resp_post(body)
275        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
276
277    @mock.patch('cinder.objects.Service.get_by_args')
278    def test_manage_volume_service_not_found_on_host(self, mock_service):
279        """Test correct failure when host having no volume service on it."""
280        body = {'volume': {'host': 'host_ok',
281                           'ref': 'fake_ref'}}
282        mock_service.side_effect = exception.ServiceNotFound(
283            service_id='cinder-volume',
284            host='host_ok')
285        res = self._get_resp_post(body)
286        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
287
288    def test_manage_volume_missing_ref(self):
289        """Test correct failure when the ref is not specified."""
290        body = {'volume': {'host': 'host_ok'}}
291        res = self._get_resp_post(body)
292        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
293
294    def test_manage_volume_with_invalid_bootable(self):
295        """Test correct failure when invalid bool value is specified."""
296        body = {'volume': {'host': 'host_ok',
297                           'ref': 'fake_ref',
298                           'bootable': 'InvalidBool'}}
299        res = self._get_resp_post(body)
300        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
301
302    @mock.patch('cinder.objects.service.Service.is_up', return_value=True,
303                new_callable=mock.PropertyMock)
304    def test_manage_volume_disabled(self, mock_is_up):
305        """Test manage volume failure due to disabled service."""
306        body = {'volume': {'host': 'host_disabled', 'ref': 'fake_ref'}}
307        res = self._get_resp_post(body)
308        self.assertEqual(http_client.BAD_REQUEST, res.status_int, res)
309        self.assertEqual(exception.ServiceUnavailable.message,
310                         res.json['badRequest']['message'])
311        mock_is_up.assert_not_called()
312
313    @mock.patch('cinder.objects.service.Service.is_up', return_value=False,
314                new_callable=mock.PropertyMock)
315    def test_manage_volume_is_down(self, mock_is_up):
316        """Test manage volume failure due to down service."""
317        body = {'volume': {'host': 'host_ok', 'ref': 'fake_ref'}}
318        res = self._get_resp_post(body)
319        self.assertEqual(http_client.BAD_REQUEST, res.status_int, res)
320        self.assertEqual(exception.ServiceUnavailable.message,
321                         res.json['badRequest']['message'])
322        self.assertTrue(mock_is_up.called)
323
324    @mock.patch('cinder.volume.api.API.manage_existing', api_manage)
325    @mock.patch(
326        'cinder.api.openstack.wsgi.Controller.validate_name_and_description')
327    def test_manage_volume_volume_type_by_uuid(self, mock_validate):
328        """Tests for correct operation when a volume type is specified by ID.
329
330        We wrap cinder.volume.api.API.manage_existing so that managing is not
331        actually attempted.
332        """
333        body = {'volume': {'host': 'host_ok',
334                           'ref': 'fake_ref',
335                           'volume_type': fake.VOLUME_TYPE_ID,
336                           'bootable': True}}
337        res = self._get_resp_post(body)
338        self.assertEqual(http_client.ACCEPTED, res.status_int)
339        self.assertTrue(mock_validate.called)
340
341    @mock.patch('cinder.volume.api.API.manage_existing', api_manage)
342    @mock.patch(
343        'cinder.api.openstack.wsgi.Controller.validate_name_and_description')
344    def test_manage_volume_volume_type_by_name(self, mock_validate):
345        """Tests for correct operation when a volume type is specified by name.
346
347        We wrap cinder.volume.api.API.manage_existing so that managing is not
348        actually attempted.
349        """
350        body = {'volume': {'host': 'host_ok',
351                           'ref': 'fake_ref',
352                           'volume_type': 'good_fakevt'}}
353        res = self._get_resp_post(body)
354        self.assertEqual(http_client.ACCEPTED, res.status_int)
355        self.assertTrue(mock_validate.called)
356
357    def test_manage_volume_bad_volume_type_by_uuid(self):
358        """Test failure on nonexistent volume type specified by ID."""
359        body = {'volume': {'host': 'host_ok',
360                           'ref': 'fake_ref',
361                           'volume_type': fake.WILL_NOT_BE_FOUND_ID}}
362        res = self._get_resp_post(body)
363        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
364
365    def test_manage_volume_bad_volume_type_by_name(self):
366        """Test failure on nonexistent volume type specified by name."""
367        body = {'volume': {'host': 'host_ok',
368                           'ref': 'fake_ref',
369                           'volume_type': 'bad_fakevt'}}
370        res = self._get_resp_post(body)
371        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
372
373    def _get_resp_get(self, host, detailed, paging, admin=True):
374        """Helper to execute a GET os-volume-manage API call."""
375        params = {'host': host}
376        if paging:
377            params.update({'marker': '1234', 'limit': 10,
378                           'offset': 4, 'sort': 'reference:asc'})
379        query_string = "?%s" % urlencode(params)
380        detail = ""
381        if detailed:
382            detail = "/detail"
383        url = "/v2/%s/os-volume-manage%s%s" % (fake.PROJECT_ID, detail,
384                                               query_string)
385        req = webob.Request.blank(url)
386        req.method = 'GET'
387        req.headers['Content-Type'] = 'application/json'
388        req.environ['cinder.context'] = (self._admin_ctxt if admin
389                                         else self._non_admin_ctxt)
390        res = req.get_response(app())
391        return res
392
393    @mock.patch('cinder.volume.api.API.get_manageable_volumes',
394                wraps=api_get_manageable_volumes)
395    def test_get_manageable_volumes_non_admin(self, mock_api_manageable):
396        res = self._get_resp_get('fakehost', False, False, admin=False)
397        self.assertEqual(http_client.FORBIDDEN, res.status_int)
398        mock_api_manageable.assert_not_called()
399        res = self._get_resp_get('fakehost', True, False, admin=False)
400        self.assertEqual(http_client.FORBIDDEN, res.status_int)
401        mock_api_manageable.assert_not_called()
402
403    @mock.patch('cinder.volume.api.API.get_manageable_volumes',
404                wraps=api_get_manageable_volumes)
405    def test_get_manageable_volumes_ok(self, mock_api_manageable):
406        res = self._get_resp_get('fakehost', False, True)
407        exp = {'manageable-volumes':
408               [{'reference':
409                 {'source-name':
410                  'volume-%s' % fake.VOLUME_ID},
411                 'size': 4, 'safe_to_manage': False},
412                {'reference': {'source-name': 'myvol'},
413                 'size': 5, 'safe_to_manage': True}]}
414        self.assertEqual(http_client.OK, res.status_int)
415        self.assertEqual(exp, jsonutils.loads(res.body))
416        mock_api_manageable.assert_called_once_with(
417            self._admin_ctxt, 'fakehost', None, limit=10, marker='1234',
418            offset=4, sort_dirs=['asc'], sort_keys=['reference'])
419
420    @mock.patch('cinder.volume.api.API.get_manageable_volumes',
421                side_effect=messaging.RemoteError(
422                    exc_type='InvalidInput', value='marker not found: 1234'))
423    def test_get_manageable_volumes_non_existent_marker(self,
424                                                        mock_api_manageable):
425        res = self._get_resp_get('fakehost', detailed=False, paging=True)
426        self.assertEqual(400, res.status_int)
427        self.assertTrue(mock_api_manageable.called)
428
429    @mock.patch('cinder.volume.api.API.get_manageable_volumes',
430                wraps=api_get_manageable_volumes)
431    def test_get_manageable_volumes_detailed_ok(self, mock_api_manageable):
432        res = self._get_resp_get('fakehost', True, False)
433        exp = {'manageable-volumes':
434               [{'reference': {'source-name': 'volume-%s' % fake.VOLUME_ID},
435                 'size': 4, 'reason_not_safe': 'volume in use',
436                 'cinder_id': fake.VOLUME_ID, 'safe_to_manage': False,
437                 'extra_info': 'qos_setting:high'},
438                {'reference': {'source-name': 'myvol'}, 'cinder_id': None,
439                 'size': 5, 'reason_not_safe': None, 'safe_to_manage': True,
440                 'extra_info': 'qos_setting:low'}]}
441        self.assertEqual(http_client.OK, res.status_int)
442        self.assertEqual(exp, jsonutils.loads(res.body))
443        mock_api_manageable.assert_called_once_with(
444            self._admin_ctxt, 'fakehost', None, limit=CONF.osapi_max_limit,
445            marker=None, offset=0, sort_dirs=['desc'],
446            sort_keys=['reference'])
447
448    @mock.patch('cinder.volume.api.API.get_manageable_volumes',
449                side_effect=messaging.RemoteError(
450                    exc_type='InvalidInput', value='marker not found: 1234'))
451    def test_get_manageable_volumes_non_existent_marker_detailed(
452            self, mock_api_manageable):
453        res = self._get_resp_get('fakehost', detailed=True, paging=True)
454        self.assertEqual(400, res.status_int)
455        self.assertTrue(mock_api_manageable.called)
456
457    @ddt.data({'a' * 256: 'a'},
458              {'a': 'a' * 256},
459              {'': 'a'},
460              {'a': None},
461              )
462    def test_manage_volume_with_invalid_metadata(self, value):
463        body = {'volume': {'host': 'host_ok',
464                           'ref': 'fake_ref',
465                           "metadata": value}}
466        res = self._get_resp_post(body)
467        self.assertEqual(http_client.BAD_REQUEST, res.status_int)
468
469    @mock.patch('cinder.objects.service.Service.is_up', return_value=True,
470                new_callable=mock.PropertyMock)
471    def test_get_manageable_volumes_disabled(self, mock_is_up):
472        res = self._get_resp_get('host_disabled', False, True)
473        self.assertEqual(http_client.BAD_REQUEST, res.status_int, res)
474        self.assertEqual(exception.ServiceUnavailable.message,
475                         res.json['badRequest']['message'])
476        mock_is_up.assert_not_called()
477
478    @mock.patch('cinder.objects.service.Service.is_up', return_value=False,
479                new_callable=mock.PropertyMock)
480    def test_get_manageable_volumes_is_down(self, mock_is_up):
481        res = self._get_resp_get('host_ok', False, True)
482        self.assertEqual(http_client.BAD_REQUEST, res.status_int, res)
483        self.assertEqual(exception.ServiceUnavailable.message,
484                         res.json['badRequest']['message'])
485        self.assertTrue(mock_is_up.called)
486
487    @mock.patch('cinder.volume.api.API.manage_existing', wraps=api_manage_new)
488    def test_manage_volume_with_creating_status_in_v3(self, mock_api_manage):
489        """Test managing volume to return 'creating' status in V3 API."""
490        body = {'volume': {'host': 'host_ok',
491                           'ref': 'fake_ref'}}
492        res = self._get_resp_post_v3(body, mv.ETAGS)
493        self.assertEqual(http_client.ACCEPTED, res.status_int)
494        self.assertEqual(1, mock_api_manage.call_count)
495        self.assertEqual('creating',
496                         jsonutils.loads(res.body)['volume']['status'])
497
498    @mock.patch('cinder.volume.api.API.manage_existing', wraps=api_manage_new)
499    def test_manage_volume_with_creating_status_in_v2(self, mock_api_manage):
500        """Test managing volume to return 'creating' status in V2 API."""
501
502        body = {'volume': {'host': 'host_ok',
503                           'ref': 'fake_ref'}}
504        res = self._get_resp_post(body)
505        self.assertEqual(http_client.ACCEPTED, res.status_int)
506        self.assertEqual(1, mock_api_manage.call_count)
507        self.assertEqual('creating',
508                         jsonutils.loads(res.body)['volume']['status'])
509