Add devices-list to support /v1/devices

This commit adds a new command, 'craton device-list', to support the
endpoint /v1/devices. Currently the endpoint only supports GET requests.

The command supports filtering by cloud, cell, region, parent and active
status. In addition, where a parent is specified, it is also possible to
request descendants.

The API response body is of the form:

    {
        "devices: {
            "hosts": [
            ],
            "network-devices": [
            ],
        },
        "links": [
        ],
    }

This object differs, from the other response bodies that return
collections, in that devices is not an array but is instead an object
whose values are arrays. This difference has necessitated modifying the
list and pagination functionality to support nested data.

Change-Id: I7cdec9935a360dae3910802f210ab9341ef7a696
Closes-bug: 1668705
This commit is contained in:
git-harry 2017-03-01 16:47:14 +00:00
parent c558ab65cf
commit 239bfd30c6
10 changed files with 692 additions and 13 deletions

@ -105,12 +105,15 @@ class CRUDClient(object):
def list(self, skip_merge=False, **kwargs):
"""Generate the items from this endpoint."""
autopaginate = kwargs.pop('autopaginate', True)
nested = kwargs.pop('nested', False)
self.merge_request_arguments(kwargs, skip_merge)
url = self.build_url(path_arguments=kwargs)
response_generator = self.session.paginate(
url,
autopaginate=autopaginate,
items_key=(self.key + 's'),
nested=nested,
params=kwargs,
)
for response, items in response_generator:

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Craton-specific session details."""
from itertools import chain
import logging
from keystoneauth1 import session as ksa_session
@ -233,7 +234,8 @@ class Session(object):
return response
def paginate(self, url, items_key, autopaginate=True, **kwargs):
def paginate(self, url, items_key, autopaginate=True, nested=False,
**kwargs):
"""Make a GET request to a paginated resource.
If :param:`autopaginate` is set to ``True``, this will automatically
@ -256,21 +258,24 @@ class Session(object):
Determines whether or not this method continues requesting items
automatically after the first page.
"""
response = self.get(url, **kwargs)
json_body = response.json()
items = json_body[items_key]
links = json_body['links']
yield response, items
while autopaginate and len(items) > 0:
url = _find_next_link(links)
if url is None:
break
response = self.get(url)
get_items = True
while get_items:
response = self.get(url, **kwargs)
json_body = response.json()
items = json_body[items_key]
links = json_body['links']
if nested:
items = list(chain(*json_body[items_key].values()))
else:
items = json_body[items_key]
yield response, items
links = json_body['links']
url = _find_next_link(links)
kwargs = {}
get_items = url and autopaginate and len(items) > 0
def _find_next_link(links):
for link in links:

@ -0,0 +1,122 @@
# 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.
"""Hosts resource and resource shell wrapper."""
from __future__ import print_function
from cratonclient.common import cliutils
from cratonclient import exceptions as exc
from cratonclient.v1 import devices
@cliutils.arg('--fields',
nargs='+',
metavar='<fields>',
default=[],
help='Space-separated list of fields to display. '
'Only these fields will be fetched from the server.')
@cliutils.arg('--all',
action='store_true',
default=False,
help='Retrieve and show all devices. This will override '
'the provided value for --limit and automatically '
'retrieve each page of results.')
@cliutils.arg('--sort-key',
metavar='<field>',
help='Device field that will be used for sorting.')
@cliutils.arg('--sort-dir',
metavar='<direction>',
default='asc',
choices=('asc', 'desc'),
help='Sort direction: "asc" (default) or "desc".')
@cliutils.arg('--limit',
metavar='<limit>',
type=int,
help='Maximum number of devices to return.')
@cliutils.arg('--marker',
metavar='<marker>',
default=None,
help='ID of the device to use to resume listing devices.')
@cliutils.arg('--cloud',
metavar='<cloud>',
type=int,
help='ID of the cloud that the device belongs to.')
@cliutils.arg('-r', '--region',
metavar='<region>',
type=int,
help='ID of the region that the device belongs to.')
@cliutils.arg('-c', '--cell',
metavar='<cell>',
type=int,
help='Integer ID of the cell that contains '
'the desired list of devices.')
@cliutils.arg('--parent',
metavar='<parent>',
type=int,
help='Parent ID of required devices.')
@cliutils.arg('--descendants',
default=False,
action='store_true',
help='When parent is also specified, include all descendants.')
@cliutils.arg('--active',
metavar='<active>',
choices=("true", "false"),
help='Filter devices by their active state.')
def do_device_list(cc, args):
"""List all devices."""
params = {}
default_fields = [
'cloud_id', 'region_id', 'cell_id', 'parent_id', 'id', 'name',
'device_type', 'active',
]
if args.limit is not None:
if args.limit < 0:
raise exc.CommandError('Invalid limit specified. Expected '
'non-negative limit, got {0}'
.format(args.limit))
params['limit'] = args.limit
if args.all is True:
params['limit'] = 100
if args.fields:
try:
fields = {x: devices.DEVICE_FIELDS[x] for x in args.fields}
except KeyError as err:
raise exc.CommandError('Invalid field "{}"'.format(err.args[0]))
else:
fields = default_fields
sort_key = args.sort_key and args.sort_key.lower()
if sort_key is not None:
if sort_key not in devices.DEVICE_FIELDS:
raise exc.CommandError(
'{0} is an invalid key for sorting, valid values for '
'--sort-key are: {1}'.format(
args.sort_key, devices.DEVICE_FIELDS.keys()
)
)
params['sort_keys'] = sort_key
params['sort_dir'] = args.sort_dir
params['marker'] = args.marker
params['autopaginate'] = args.all
if args.parent:
params['parent_id'] = args.parent
params['descendants'] = args.descendants
if args.cloud:
params['cloud_id'] = args.cloud
if args.region:
params['region_id'] = args.region
if args.cell:
params['cell_id'] = args.cell
if args.active:
params['active'] = args.active
devices_list = cc.devices.list(**params)
args.formatter.configure(fields=list(fields)).handle(devices_list)

@ -12,6 +12,7 @@
"""Command-line interface to the OpenStack Craton API V1."""
from cratonclient.shell.v1 import cells_shell
from cratonclient.shell.v1 import clouds_shell
from cratonclient.shell.v1 import devices_shell
from cratonclient.shell.v1 import hosts_shell
from cratonclient.shell.v1 import projects_shell
from cratonclient.shell.v1 import regions_shell
@ -22,6 +23,7 @@ COMMAND_MODULES = [
projects_shell,
clouds_shell,
regions_shell,
devices_shell,
hosts_shell,
cells_shell,
]

@ -0,0 +1,182 @@
# 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.
"""Tests for `cratonclient.shell.v1.devices_shell` module."""
import mock
import re
from cratonclient import exceptions as exc
from cratonclient.tests.integration.shell import base
class TestDevicesShell(base.ShellTestCase):
"""Test our craton devices shell commands."""
re_options = re.DOTALL | re.MULTILINE
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_success(self, mock_list):
"""Verify that no arguments prints out all project devices."""
self.shell('device-list')
self.assertTrue(mock_list.called)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_parse_param_success(self, mock_list):
"""Verify that success of parsing a subcommand argument."""
self.shell('device-list --limit 0')
self.assertTrue(mock_list.called)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_limit_0_success(self, mock_list):
"""Verify that --limit 0 prints out all project devices."""
self.shell('device-list --limit 0')
mock_list.assert_called_once_with(
limit=0,
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_limit_positive_num_success(self, mock_list):
"""Verify --limit X, where X is a positive integer, succeeds.
The command will print out X number of project devices.
"""
self.shell('device-list --limit 1')
mock_list.assert_called_once_with(
limit=1,
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
def test_device_list_limit_negative_num_failure(self):
"""Verify --limit X, where X is a negative integer, fails.
The command will cause a Command Error message response.
"""
self.assertRaises(exc.CommandError,
self.shell,
'device-list -r 1 --limit -1')
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_cell_success(self, mock_list):
"""Verify --cell arguments successfully pass cell to Client."""
for cell_arg in ['-c', '--cell']:
self.shell('device-list {0} 1'.format(cell_arg))
mock_list.assert_called_once_with(
cell_id=1,
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
mock_list.reset_mock()
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_fields_success(self, mock_list):
"""Verify --fields argument successfully passed to Client."""
self.shell('device-list --fields id name')
mock_list.assert_called_once_with(
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_keys_field_key_success(self, mock_list):
"""Verify --sort-key arguments successfully passed to Client."""
self.shell('device-list --sort-key cell_id')
mock_list.assert_called_once_with(
sort_keys='cell_id',
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
def test_device_list_sort_keys_invalid(self):
"""Verify --sort-key with invalid args, fails with Command Error."""
self.assertRaises(exc.CommandError,
self.shell,
'device-list --sort-key invalid')
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_dir_not_passed_without_sort_key(self, mock_list):
"""Verify --sort-dir arg ignored without --sort-key."""
self.shell('device-list --sort-dir desc')
mock_list.assert_called_once_with(
sort_dir='desc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_dir_asc_success(self, mock_list):
"""Verify --sort-dir asc successfully passed to Client."""
self.shell('device-list --sort-key name --sort-dir asc')
mock_list.assert_called_once_with(
sort_keys='name',
sort_dir='asc',
descendants=False,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_sort_dir_desc_success(self, mock_list):
"""Verify --sort-dir desc successfully passed to Client."""
self.shell('device-list --sort-key name --sort-dir desc')
mock_list.assert_called_once_with(
sort_keys='name',
sort_dir='desc',
descendants=False,
marker=None,
autopaginate=False,
)
def test_device_list_sort_dir_invalid_value(self):
"""Verify --sort-dir with invalid args, fails with Command Error."""
(_, error) = self.shell(
'device-list -r 1 --sort-key name --sort-dir invalid'
)
self.assertIn("invalid choice: 'invalid'", error)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_filter_by_parent_success(self, mock_list):
"""Verify --parent ID successfully passed to Client."""
self.shell('device-list --parent 12345')
mock_list.assert_called_once_with(
sort_dir='asc',
descendants=False,
parent_id=12345,
marker=None,
autopaginate=False,
)
@mock.patch('cratonclient.v1.devices.DeviceManager.list')
def test_device_list_filter_by_parent_descendants_success(self, mock_list):
"""Verify --parent ID successfully passed to Client."""
self.shell('device-list --parent 12345 --descendants')
mock_list.assert_called_once_with(
sort_dir='asc',
parent_id=12345,
descendants=True,
marker=None,
autopaginate=False,
)

@ -0,0 +1,257 @@
# -*- coding: utf-8 -*-
# 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.
"""Tests for the shell functions for the devices resource."""
from cratonclient.shell.v1 import devices_shell
from cratonclient.tests.unit.shell import base
class TestDoDeviceList(base.TestShellCommandUsingPrintList):
"""Unit tests for the device list command."""
def args_for(self, **kwargs):
"""Generate a Namespace for do_device_list."""
kwargs.setdefault('cloud', None)
kwargs.setdefault('region', None)
kwargs.setdefault('cell', None)
kwargs.setdefault('parent', None)
kwargs.setdefault('descendants', False)
kwargs.setdefault('active', None)
kwargs.setdefault('limit', None)
kwargs.setdefault('sort_key', None)
kwargs.setdefault('sort_dir', 'asc')
kwargs.setdefault('fields', [])
kwargs.setdefault('marker', None)
kwargs.setdefault('all', False)
return super(TestDoDeviceList, self).args_for(**kwargs)
def test_only_required_parameters(self):
"""Verify the behaviour with the minimum number of params."""
args = self.args_for()
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_parent_id(self):
"""Verify that we include the parent_id in the params."""
args = self.args_for(parent=789)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
parent_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_parent_id_and_descendants(self):
"""Verify that the parent_id and descendants is in the params."""
args = self.args_for(parent=789, descendants=False)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
parent_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_region_id(self):
"""Verify that we include the region_id in the params."""
args = self.args_for(region=789)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
region_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_cell_id(self):
"""Verify that we include the cell_id in the params."""
args = self.args_for(cell=789)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
cell_id=789,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_cloud_id(self):
"""Verify that we include the cell_id in the params."""
args = self.args_for(cloud=123)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
cloud_id=123,
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_with_limit(self):
"""Verify the behaviour with --limit specified."""
args = self.args_for(limit=20)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
limit=20,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'active', 'cell_id', 'cloud_id', 'device_type', 'id', 'name',
'parent_id', 'region_id',
])
def test_negative_limit_raises_command_error(self):
"""Verify that we forbid negative limit values."""
args = self.args_for(limit=-10)
self.assertRaisesCommandErrorWith(devices_shell.do_device_list, args)
self.assertNothingWasCalled()
def test_fields(self):
"""Verify that we can specify custom fields."""
args = self.args_for(fields=['id', 'name', 'cell_id'])
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
autopaginate=False,
marker=None,
)
self.assertSortedPrintListFieldsEqualTo([
'cell_id', 'id', 'name',
])
def test_invalid_sort_key(self):
"""Verify that we disallow invalid sort keys."""
args = self.args_for(sort_key='my-fake-sort-key')
self.assertRaisesCommandErrorWith(
devices_shell.do_device_list, args
)
self.assertNothingWasCalled()
def test_sort_key(self):
"""Verify we pass sort_key to our list call."""
args = self.args_for(sort_key='ip_address')
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_keys='ip_address',
sort_dir='asc',
autopaginate=False,
marker=None,
)
def test_invalid_fields_raise_command_error(self):
"""Verify sending an invalid field raises a CommandError."""
args = self.args_for(fields=['fake-field', 'id'])
self.assertRaisesCommandErrorWith(
devices_shell.do_device_list, args,
)
self.assertNothingWasCalled()
def test_autopagination(self):
"""Verify autopagination is controlled by --all."""
args = self.args_for(all=True)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
limit=100,
marker=None,
autopaginate=True,
)
def test_autopagination_overrides_limit(self):
"""Verify --all overrides --limit."""
args = self.args_for(all=True, limit=30)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
limit=100,
marker=None,
autopaginate=True,
)
def test_marker_pass_through(self):
"""Verify we pass our marker through to the client."""
args = self.args_for(marker=42)
devices_shell.do_device_list(self.craton_client, args)
self.craton_client.devices.list.assert_called_once_with(
descendants=False,
sort_dir='asc',
marker=42,
autopaginate=False,
)

@ -183,6 +183,7 @@ class TestCRUDClient(base.TestCase):
autopaginate=True,
items_key='test_keys',
params={'sort': 'asc'},
nested=False,
)
self.resource_spec.assert_called_once_with(
self.client,

@ -12,6 +12,9 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Session specific unit tests."""
from itertools import chain
from operator import itemgetter
from keystoneauth1 import session as ksa_session
import mock
@ -162,3 +165,57 @@ class TestSession(base.TestCase):
),
],
)
def test_paginate_nested_response(self):
"""Verify Session#paginate can extract nested lists."""
responses = [
self.create_response(
{
"items-sub-type-1": [
{"id": 1},
],
"items-sub-type-2": [
{"id": 2},
],
},
"http://example.com/v1/items?limit=30&marker=2"
),
self.create_response(
{
"items-sub-type-1": [],
"items-sub-type-2": [],
},
""
),
]
mock_session = mock.Mock()
mock_session.request.side_effect = responses
craton_session = session.Session(session=mock_session)
paginated_items = list(craton_session.paginate(
url='http://example.com/v1/items',
items_key='items',
autopaginate=True,
nested=True,
))
self.assertEqual(2, len(paginated_items))
resp_items = sorted(
chain(*(resp[1] for resp in paginated_items)), key=itemgetter("id")
)
self.assertListEqual([{"id": 1}, {"id": 2}], resp_items)
self.assertListEqual(
mock_session.request.call_args_list,
[
mock.call(
method='GET',
url='http://example.com/v1/items',
endpoint_filter={'service_type': 'fleet_management'},
),
mock.call(
method='GET',
url='http://example.com/v1/items?limit=30&marker=2',
endpoint_filter={'service_type': 'fleet_management'},
),
]
)

@ -14,6 +14,7 @@
"""Top-level client for version 1 of Craton's API."""
from cratonclient.v1 import cells
from cratonclient.v1 import clouds
from cratonclient.v1 import devices
from cratonclient.v1 import hosts
from cratonclient.v1 import projects
from cratonclient.v1 import regions
@ -44,4 +45,5 @@ class Client(object):
self.cells = cells.CellManager(**manager_kwargs)
self.projects = projects.ProjectManager(**manager_kwargs)
self.clouds = clouds.CloudManager(**manager_kwargs)
self.devices = devices.DeviceManager(**manager_kwargs)
self.regions = regions.RegionManager(**manager_kwargs)

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# 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.
"""Devices manager code."""
from cratonclient import crud
class Device(crud.Resource):
"""Representation of a Device."""
pass
class DeviceManager(crud.CRUDClient):
"""A manager for devices."""
key = 'device'
base_path = '/devices'
resource_class = Device
def list(self, **kwargs):
"""Generate the items from this endpoint."""
return super(DeviceManager, self).list(nested=True, **kwargs)
DEVICE_FIELDS = {
'id': 'ID',
'project_id': 'Project ID',
'cloud_id': 'Cloud ID',
'region_id': 'Region ID',
'cell_id': 'Cell ID',
'parent_id': 'Parent ID',
'name': 'Name',
'ip_address': 'IP Address',
'device_type': 'Device Type',
'note': 'Note',
'created_at': 'Created At',
'updated_at': 'Updated At'
}