Merge "CLI and client support for get/set/delete of resource vars"

This commit is contained in:
Jenkins 2017-03-06 17:18:14 +00:00 committed by Gerrit Code Review
commit 46d8cc83e8
15 changed files with 611 additions and 10 deletions

View File

@ -12,7 +12,15 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Craton CLI helper classes and functions."""
import functools
import json
import os
import sys
from oslo_utils import encodeutils
from oslo_utils import strutils
from cratonclient import exceptions as exc
def arg(*args, **kwargs):
@ -66,6 +74,43 @@ def field_labels_from(attributes):
return [field.replace('_', ' ').title() for field in attributes]
def handle_shell_exception(function):
"""Generic error handler for shell methods."""
@functools.wraps(function)
def wrapper(cc, args):
prop_map = {
"vars": "variables"
}
try:
function(cc, args)
except exc.ClientException as client_exc:
# NOTE(thomasem): All shell methods follow a similar pattern,
# so we can parse this name to get intended parts for
# messaging what went wrong to the end-user.
# The pattern is "do_<resource>_(<prop>_)<verb>", like
# do_project_show or do_project_vars_get, where <prop> is
# not guaranteed to be there, but will afford support for
# actions on some property of the resource.
parsed = function.__name__.split('_')
resource = parsed[1]
verb = parsed[-1]
prop = parsed[2] if len(parsed) > 3 else None
msg = 'Failed to {}'.format(verb)
if prop:
# NOTE(thomasem): Prop would be something like "vars" in
# "do_project_vars_get".
msg = '{} {}'.format(msg, prop_map.get(prop))
# NOTE(thomasem): Append resource and ClientException details
# to error message.
msg = '{} for {} {} due to "{}: {}"'.format(
msg, resource, args.id, client_exc.__class__,
encodeutils.exception_to_unicode(client_exc)
)
raise exc.CommandError(msg)
return wrapper
def env(*args, **kwargs):
"""Return the first environment variable set.
@ -76,3 +121,56 @@ def env(*args, **kwargs):
if value:
return value
return kwargs.get('default', '')
def convert_arg_value(v):
"""Convert different user inputs to normalized type."""
# NOTE(thomasem): Handle case where one wants to escape this value
# conversion using the format key='"value"'
if v.startswith('"'):
return v.strip('"')
lower_v = v.lower()
if strutils.is_int_like(v):
return int(v)
if strutils.is_valid_boolstr(lower_v):
return strutils.bool_from_string(lower_v)
if lower_v == 'null' or lower_v == 'none':
return None
try:
return float(v)
except ValueError:
pass
return v
def variable_updates(variables):
"""Derive list of expected variables for a resource and set them."""
update_variables = {}
delete_variables = set()
for variable in variables:
k, v = variable.split('=', 1)
if v:
update_variables[k] = convert_arg_value(v)
else:
delete_variables.add(k)
if not sys.stdin.isatty():
if update_variables or delete_variables:
raise exc.CommandError("Cannot use variable settings from both "
"stdin and command line arguments. Please "
"choose one or the other.")
update_variables = json.load(sys.stdin)
return (update_variables, list(delete_variables))
def variable_deletes(variables):
"""Delete a list of variables (by key) from a resource."""
if not sys.stdin.isatty():
if variables:
raise exc.CommandError("Cannot use variable settings from both "
"stdin and command line arguments. Please "
"choose one or the other.")
delete_variables = json.load(sys.stdin)
else:
delete_variables = variables
return delete_variables

View File

@ -20,7 +20,7 @@ from oslo_utils import strutils
class CRUDClient(object):
"""Class that handles the basic create, read, upload, delete workflow."""
key = None
key = ""
base_path = None
resource_class = None
@ -45,7 +45,7 @@ class CRUDClient(object):
base_path = '/regions'
And it's ``key``, e.g.,
And its ``key``, e.g.,
.. code-block:: python
@ -128,16 +128,26 @@ class CRUDClient(object):
response = self.session.put(url, json=kwargs)
return self.resource_class(self, response.json(), loaded=True)
def delete(self, item_id=None, skip_merge=True, **kwargs):
def delete(self, item_id=None, skip_merge=True, json=None, **kwargs):
"""Delete the item based on the keyword arguments provided."""
self.merge_request_arguments(kwargs, skip_merge)
kwargs.setdefault(self.key + '_id', item_id)
url = self.build_url(path_arguments=kwargs)
response = self.session.delete(url, params=kwargs)
response = self.session.delete(url, params=kwargs, json=json)
if 200 <= response.status_code < 300:
return True
return False
def __repr__(self):
"""Return string representation of a Variable."""
return '%(class)s(%(session)s, %(url)s, %(extra_request_kwargs)s)' % \
{
"class": self.__class__.__name__,
"session": self.session,
"url": self.url,
"extra_request_kwargs": self.extra_request_kwargs,
}
# NOTE(sigmavirus24): Credit for this Resource object goes to the
# keystoneclient developers and contributors.
@ -149,6 +159,7 @@ class Resource(object):
HUMAN_ID = False
NAME_ATTR = 'name'
subresource_managers = {}
def __init__(self, manager, info, loaded=False):
"""Populate and bind to a manager.
@ -162,6 +173,15 @@ class Resource(object):
self._add_details(info)
self._loaded = loaded
session = self.manager.session
subresource_base_url = self.manager.build_url(
{"{0}_id".format(self.manager.key): self.id}
)
for attribute, cls in self.subresource_managers.items():
setattr(self, attribute,
cls(session, subresource_base_url,
**self.manager.extra_request_kwargs))
def __repr__(self):
"""Return string representation of resource attributes."""
reprkeys = sorted(k

View File

@ -10,7 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Base class implementation for formatting plugins."""
from cratonclient import crud
class Formatter(object):
@ -66,7 +65,8 @@ class Formatter(object):
we will not know how to handle it. In that case, we will raise a
ValueError.
"""
if isinstance(item_to_format, crud.Resource):
to_dict = getattr(item_to_format, 'to_dict', None)
if to_dict is not None:
self.handle_instance(item_to_format)
return

View File

@ -14,6 +14,9 @@
"""Hosts resource and resource shell wrapper."""
from __future__ import print_function
import argparse
import sys
from cratonclient.common import cliutils
from cratonclient import exceptions as exc
@ -279,3 +282,60 @@ def do_host_delete(cc, args):
else:
print("Host {0} was {1} deleted.".
format(args.id, 'successfully' if response else 'not'))
@cliutils.arg('id',
metavar='<host>',
type=int,
help='ID or name of the host.')
@cliutils.handle_shell_exception
def do_host_vars_get(cc, args):
"""Get variables for a host."""
variables = cc.hosts.get(args.id).variables.get()
formatter = args.formatter.configure(dict_property="Variable", wrap=72)
formatter.handle(variables)
@cliutils.arg('id',
metavar='<host>',
type=int,
help='ID of the host.')
@cliutils.arg('variables', nargs=argparse.REMAINDER)
@cliutils.handle_shell_exception
def do_host_vars_set(cc, args):
"""Set variables for a host."""
host_id = args.id
if not args.variables and sys.stdin.isatty():
raise exc.CommandError(
'Nothing to update... Please specify variables to set in the '
'following format: "key=value". You may also specify variables to '
'delete by key using the format: "key="'
)
adds, deletes = cliutils.variable_updates(args.variables)
variables = cc.hosts.get(host_id).variables
if deletes:
variables.delete(*deletes)
variables.update(**adds)
formatter = args.formatter.configure(wrap=72, dict_property="Variable")
formatter.handle(variables.get())
@cliutils.arg('id',
metavar='<host>',
type=int,
help='ID of the host.')
@cliutils.arg('variables', nargs=argparse.REMAINDER)
@cliutils.handle_shell_exception
def do_host_vars_delete(cc, args):
"""Delete variables for a host by key."""
host_id = args.id
if not args.variables and sys.stdin.isatty():
raise exc.CommandError(
'Nothing to delete... Please specify variables to delete by '
'listing the keys you wish to delete separated by spaces.'
)
deletes = cliutils.variable_deletes(args.variables)
variables = cc.hosts.get(host_id).variables
response = variables.delete(*deletes)
print("Variables {0} deleted.".
format('successfully' if response else 'not'))

View File

@ -22,6 +22,7 @@ from cratonclient import exceptions as exc
from cratonclient.shell.v1 import hosts_shell
from cratonclient.tests.integration.shell import base
from cratonclient.v1 import hosts
from cratonclient.v1 import variables
class TestHostsShell(base.ShellTestCase):
@ -389,3 +390,101 @@ class TestHostsShell(base.ShellTestCase):
test_args = Namespace(id=1, region=1)
hosts_shell.do_host_delete(client, test_args)
mock_delete.assert_called_once_with(vars(test_args)['id'])
class TestHostsVarsShell(base.ShellTestCase):
"""Test Host Variable shell calls."""
def setUp(self):
"""Basic set up for all tests in this suite."""
super(TestHostsVarsShell, self).setUp()
self.host_url = 'http://127.0.0.1/v1/hosts/1'
self.variables_url = '{}/variables'.format(self.host_url)
self.test_args = Namespace(id=1, formatter=mock.Mock())
# NOTE(thomasem): Make all calls seem like they come from CLI args
self.stdin_patcher = \
mock.patch('cratonclient.common.cliutils.sys.stdin')
self.patched_stdin = self.stdin_patcher.start()
self.patched_stdin.isatty.return_value = True
# NOTE(thomasem): Mock out a session object to assert resulting API
# calls
self.mock_session = mock.Mock()
self.mock_get_response = self.mock_session.get.return_value
self.mock_put_response = self.mock_session.put.return_value
self.mock_delete_response = self.mock_session.delete.return_value
self.mock_delete_response.status_code = 204
# NOTE(thomasem): Mock out a client to assert craton Python API calls
self.client = mock.Mock()
self.mock_host_resource = self.client.hosts.get.return_value
self.mock_host_resource.variables = variables.VariableManager(
self.mock_session, self.host_url
)
def tearDown(self):
"""Clean up between tests."""
super(TestHostsVarsShell, self).tearDown()
self.stdin_patcher.stop()
def test_do_host_vars_get_gets_correct_host(self):
"""Assert the proper host is retrieved when calling get."""
self.mock_get_response.json.return_value = \
{"variables": {"foo": "bar"}}
hosts_shell.do_host_vars_get(self.client, self.test_args)
self.client.hosts.get.assert_called_once_with(
vars(self.test_args)['id'])
def test_do_host_vars_delete_gets_correct_host(self):
"""Assert the proper host is retrieved when calling delete."""
self.test_args.variables = ['foo', 'bar']
hosts_shell.do_host_vars_delete(self.client, self.test_args)
self.client.hosts.get.assert_called_once_with(
vars(self.test_args)['id'])
def test_do_host_vars_update_gets_correct_host(self):
"""Assert the proper host is retrieved when calling update."""
self.test_args.variables = ['foo=', 'bar=']
mock_resp_json = {"variables": {"foo": "bar"}}
self.mock_get_response.json.return_value = mock_resp_json
self.mock_put_response.json.return_value = mock_resp_json
hosts_shell.do_host_vars_set(self.client, self.test_args)
self.client.hosts.get.assert_called_once_with(
vars(self.test_args)['id'])
def test_do_host_vars_get_calls_session_get(self):
"""Assert the proper host is retrieved when calling get."""
self.mock_get_response.json.return_value = \
{"variables": {"foo": "bar"}}
hosts_shell.do_host_vars_get(self.client, self.test_args)
self.mock_session.get.assert_called_once_with(self.variables_url)
def test_do_host_vars_delete_calls_session_delete(self):
"""Verify that do host-vars-delete calls expected session.delete."""
self.test_args.variables = ['foo', 'bar']
hosts_shell.do_host_vars_delete(self.client, self.test_args)
self.mock_session.delete.assert_called_once_with(
self.variables_url,
json=('foo', 'bar'),
params={},
)
def test_do_host_vars_update_calls_session_put(self):
"""Verify that do host-vars-delete calls expected session.delete."""
self.test_args.variables = ['foo=baz', 'bar=boo', 'test=']
mock_resp_json = {"variables": {"foo": "bar"}}
self.mock_get_response.json.return_value = mock_resp_json
self.mock_put_response.json.return_value = mock_resp_json
hosts_shell.do_host_vars_set(self.client, self.test_args)
self.mock_session.delete.assert_called_once_with(
self.variables_url,
json=('test',),
params={},
)
self.mock_session.put.assert_called_once_with(
self.variables_url,
json={'foo': 'baz', 'bar': 'boo'}
)

View File

@ -108,6 +108,7 @@ class TestCrudIntegration(base.TestCase):
self.session.request.assert_called_once_with(
method='DELETE',
url='http://example.com/v1/test/1',
json=None,
params={},
endpoint_filter={'service_type': 'fleet_management'},
)

View File

@ -0,0 +1 @@
"""Unit tests for cratonclient.common submodules."""

View File

@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
# Copyright 2010-2011 OpenStack Foundation
# Copyright (c) 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
"""Unit tests for the cratonclient.crud module members."""
import mock
from cratonclient.common import cliutils
from cratonclient.tests import base
class TestCLIUtils(base.TestCase):
"""Test for the CRUDClient class."""
def test_convert_arg_value_bool(self):
"""Assert bool conversion."""
trues = ['true', 'TRUE', 'True', 'trUE']
falses = ['false', 'FALSE', 'False', 'falSe']
for true in trues:
self.assertTrue(cliutils.convert_arg_value(true))
for false in falses:
self.assertFalse(cliutils.convert_arg_value(false))
def test_convert_arg_value_none(self):
"""Assert None conversion."""
nones = ['none', 'null', 'NULL', 'None', 'NONE']
for none in nones:
self.assertIsNone(cliutils.convert_arg_value(none))
def test_convert_arg_value_integer(self):
"""Assert integer conversion."""
ints = ['1', '10', '145']
for integer in ints:
value = cliutils.convert_arg_value(integer)
self.assertTrue(isinstance(value, int))
def test_convert_arg_value_float(self):
"""Assert float conversion."""
floats = ['5.234', '1.000', '1.0001', '224.1234']
for num in floats:
value = cliutils.convert_arg_value(num)
self.assertTrue(isinstance(value, float))
def test_convert_arg_value_string(self):
"""Assert string conversion."""
strings = ["hello", "path/to/thing", "sp#cial!", "heyy:this:works"]
for string in strings:
value = cliutils.convert_arg_value(string)
self.assertTrue(isinstance(value, str))
def test_convert_arg_value_escapes(self):
"""Assert escaped conversion works to afford literal values."""
escapes = ['"007"', '"1"', '"1.0"', '"False"', '"True"', '"None"']
for escaped in escapes:
value = cliutils.convert_arg_value(escaped)
self.assertTrue(isinstance(value, str))
@mock.patch('cratonclient.common.cliutils.sys.stdin')
def test_variable_updates_from_args(self, mock_stdin):
"""Assert cliutils.variable_updates(...) when using arguments."""
test_data = ["foo=bar", "test=", "baz=1", "bumbleywump=cucumberpatch"]
mock_stdin.isatty.return_value = True
expected_updates = {
"foo": "bar",
"baz": 1,
"bumbleywump": "cucumberpatch"
}
expected_deletes = ["test"]
updates, deletes = cliutils.variable_updates(test_data)
self.assertEqual(expected_updates, updates)
self.assertEqual(expected_deletes, deletes)
@mock.patch('cratonclient.common.cliutils.sys.stdin')
def test_variable_updates_from_stdin(self, mock_stdin):
"""Assert cliutils.variable_updates(...) when using stdin."""
mock_stdin.isatty.return_value = False
mock_stdin.read.return_value = \
'{"foo": {"bar": "baz"}, "bumbleywump": "cucumberpatch"}'
expected_updates = {
"foo": {
"bar": "baz"
},
"bumbleywump": "cucumberpatch"
}
updates, deletes = cliutils.variable_updates([])
self.assertEqual(expected_updates, updates)
self.assertEqual([], deletes)
@mock.patch('cratonclient.common.cliutils.sys.stdin')
def test_variable_deletes_from_args(self, mock_stdin):
"""Assert cliutils.variable_deletes(...) when using arguments."""
test_data = ["foo", "test", "baz"]
mock_stdin.isatty.return_value = True
expected_deletes = test_data
deletes = cliutils.variable_deletes(test_data)
self.assertEqual(expected_deletes, deletes)
@mock.patch('cratonclient.common.cliutils.sys.stdin')
def test_variable_deletes_from_stdin(self, mock_stdin):
"""Assert cliutils.variable_deletes(...) when using stdin."""
mock_stdin.isatty.return_value = False
mock_stdin.read.return_value = \
'["foo", "test", "baz"]'
expected_deletes = ["foo", "test", "baz"]
deletes = cliutils.variable_deletes([])
self.assertEqual(expected_deletes, deletes)

View File

@ -46,7 +46,7 @@ class TestBaseFormatter(testbase.TestCase):
def test_handle_detects_resources(self):
"""Verify we handle instances explicitly."""
resource = crud.Resource(mock.Mock(), {})
resource = crud.Resource(mock.Mock(), {"id": 1234})
method = 'handle_instance'
with mock.patch.object(self.formatter, method) as handle_instance:
self.formatter.handle(resource)

View File

@ -121,6 +121,7 @@ class TestCRUDClient(base.TestCase):
self.session.delete.assert_called_once_with(
'http://example.com/v1/test/1',
json=None,
params={}
)
self.assertFalse(self.resource_spec.called)
@ -134,6 +135,7 @@ class TestCRUDClient(base.TestCase):
self.session.delete.assert_called_once_with(
'http://example.com/v1/test/1',
json=None,
params={}
)
self.assertFalse(self.resource_spec.called)

View File

@ -24,7 +24,7 @@ class TestCloud(base.TestCase):
def test_is_a_resource_instance(self):
"""Verify that a Cloud instance is an instance of a Resource."""
manager = mock.Mock()
self.assertIsInstance(clouds.Cloud(manager, {}),
self.assertIsInstance(clouds.Cloud(manager, {"id": 1234}),
crud.Resource)

View File

@ -24,7 +24,7 @@ class TestRegion(base.TestCase):
def test_is_a_resource_instance(self):
"""Verify that a Region instance is an instance of a Resource."""
manager = mock.Mock()
self.assertIsInstance(regions.Region(manager, {}),
self.assertIsInstance(regions.Region(manager, {"id": 1234}),
crud.Resource)

View File

@ -0,0 +1,67 @@
# 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.v1.clouds` module."""
from cratonclient import crud
from cratonclient.tests import base
from cratonclient.v1 import variables
import mock
class TestVariables(base.TestCase):
"""Tests for the Cloud Resource."""
def setUp(self):
"""Basic test setup."""
super(TestVariables, self).setUp()
session = mock.Mock()
self.variable_mgr = variables.VariableManager(session, '')
def test_is_a_crudclient(self):
"""Verify our CloudManager is a CRUDClient."""
self.assertIsInstance(self.variable_mgr, crud.CRUDClient)
def test_variables_dict(self):
"""Assert Variables instantiation produces sane variables dict."""
test_data = {
"foo": "bar",
"zoo": {
"baz": "boo"
}
}
resource_obj = self.variable_mgr.resource_class(
mock.Mock(),
{"variables": test_data}
)
expected_variables_dict = {
"foo": variables.Variable("foo", "bar"),
"zoo": variables.Variable("zoo", {"baz": "boo"})
}
self.assertDictEqual(expected_variables_dict,
resource_obj._variables_dict)
def test_to_dict(self):
"""Assert Variables.to_dict() produces original variables dict."""
test_data = {
"foo": "bar",
"zoo": {
"baz": "boo"
}
}
resource_obj = self.variable_mgr.resource_class(
mock.Mock(),
{"variables": test_data}
)
self.assertDictEqual(test_data, resource_obj.to_dict())

View File

@ -13,12 +13,15 @@
# under the License.
"""Hosts resource and resource manager."""
from cratonclient import crud
from cratonclient.v1 import variables
class Host(crud.Resource):
"""Representation of a Host."""
pass
subresource_managers = {
'variables': variables.VariableManager,
}
class HostManager(crud.CRUDClient):

View File

@ -0,0 +1,116 @@
# 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.
"""Variables manager code."""
from collections import MutableMapping
from cratonclient import crud
class Variable(object):
"""Represents a Craton variable key/value pair."""
def __init__(self, name, value):
"""Instantiate key/value pair."""
self.name = name
self.value = value
def __eq__(self, other):
"""Assess equality of Variable objects."""
if isinstance(other, type(self)):
return self.name == other.name and self.value == other.value
return False
def __repr__(self):
"""Return string representation of a Variable."""
return '%(class)s(name=%(name)r, value=%(value)r)' % \
{
"class": self.__class__.__name__,
"name": self.name,
"value": self.value
}
class Variables(MutableMapping):
"""Represents a dictionary of Variables."""
_var_key = 'variables'
def __init__(self, manager, info, loaded=False):
"""Instantiate a Variables dict.
Converts each value to a Variable representation of the full key/value
pair and assigns this mapping to self, as this extends the "dict"
type.
This class is intended to look like crud.Resource interface-wise, but
it's unfortunately a hack to get around some limitations of
crud.Resource, specifically how crud.Resource overlaying an API
response onto a Python class can have variable keys conflicting with
legitimate attributes of the class itself. Because this is supposed
to be a dictionary-like thing, though, we don't wish to use the
manager to make API calls when users are treating this like a dict.
"""
self._manager = manager
self._loaded = loaded
self._variables_dict = {
k: Variable(k, v)
for (k, v) in info.get(self._var_key, dict()).items()
}
def __getitem__(self, key):
"""Get item from self._variables_dict."""
return self._variables_dict[key]
def __setitem__(self, key, value):
"""Set item in self._variables_dict."""
self._variables_dict[key] = value
def __delitem__(self, key):
"""Delete item from self._variables_dict."""
del self._variables_dict[key]
def __len__(self):
"""Get length of self._variables_dict."""
return len(self._variables_dict)
def __iter__(self):
"""Return iterator of self._variables_dict."""
return iter(self._variables_dict)
def __repr__(self):
"""Return string representation of Variables."""
info = ", ".join("%s=%s" % (k, self._variables_dict[k]) for k, v in
self._variables_dict.items())
return "<%s %s>" % (self.__class__.__name__, info)
def to_dict(self):
"""Return this the original variables as a dict."""
return {k: v.value for (k, v) in self._variables_dict.items()}
class VariableManager(crud.CRUDClient):
"""A CRUD manager for variables."""
base_path = '/variables'
resource_class = Variables
def delete(self, *args, **kwargs):
"""Wrap crud.CRUDClient's delete to simplify for the variables.
One can pass in a series of keys to delete, and this will pass the
correct arguments to the crud.CRUDClient.delete function.
.. code-block:: python
>>> craton.hosts.get(1234).variables.delete('var-a', 'var-b')
<Response [204]>
"""
return super(VariableManager, self).delete(json=args, **kwargs)