From 05b4f117bbf9544d34a5ea7836d9da811d07eb8f Mon Sep 17 00:00:00 2001
From: Thomas Maddox <thomas.maddox@rackspace.com>
Date: Fri, 3 Mar 2017 17:19:29 +0000
Subject: [PATCH] CLI and client support for get/set/delete of resource vars

Functionality is supported both	for the	Python client:

host = craton.hosts.get(item_id=8)
host.variables.update(x='foo', y={'a': 47, 'b': True}, z='baz')
host.variables.delete("foo", "bar", "baz")

As well	as for the CLI:

craton host-vars-get 1
craton host-vars-set 3 x=true y=47 z=foo/bar w=3.14159
cat <<EOF | craton host-vars-set 13
{
    "glance_default_store": "not-so-swift",
    "neutron_l2_population": false,
    "make_stuff_up": true,
    "some/namespaced/variable": {"a": 1, "b": 2}
}
EOF
craton --format json host-vars-get 13 | jq -C
craton host-vars-delete 13 make_stuff_up
craton host-vars-set 13 x= y=42   # deletes x

This patch implements the basis for supporting this
in other resources as well, however we only address
hosts here as an initial implementation. We will
fast-follow with support in other resources.

Partial-Bug: 1659110
Change-Id: Id30188937518d7103d6f943cf1d038b039dc30cc
---
 cratonclient/common/cliutils.py               |  98 +++++++++++++
 cratonclient/crud.py                          |  28 +++-
 cratonclient/formatters/base.py               |   4 +-
 cratonclient/shell/v1/hosts_shell.py          |  60 ++++++++
 .../integration/shell/v1/test_hosts_shell.py  |  99 +++++++++++++
 cratonclient/tests/integration/test_crud.py   |   1 +
 cratonclient/tests/unit/common/__init__.py    |   1 +
 .../tests/unit/common/test_cliutils.py        | 134 ++++++++++++++++++
 .../unit/formatters/test_base_formatter.py    |   2 +-
 cratonclient/tests/unit/test_crud.py          |   2 +
 cratonclient/tests/unit/v1/test_clouds.py     |   2 +-
 cratonclient/tests/unit/v1/test_regions.py    |   2 +-
 cratonclient/tests/unit/v1/test_variables.py  |  67 +++++++++
 cratonclient/v1/hosts.py                      |   5 +-
 cratonclient/v1/variables.py                  | 116 +++++++++++++++
 15 files changed, 611 insertions(+), 10 deletions(-)
 create mode 100644 cratonclient/tests/unit/common/__init__.py
 create mode 100644 cratonclient/tests/unit/common/test_cliutils.py
 create mode 100644 cratonclient/tests/unit/v1/test_variables.py
 create mode 100644 cratonclient/v1/variables.py

diff --git a/cratonclient/common/cliutils.py b/cratonclient/common/cliutils.py
index 3c17e98..5c17101 100644
--- a/cratonclient/common/cliutils.py
+++ b/cratonclient/common/cliutils.py
@@ -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
diff --git a/cratonclient/crud.py b/cratonclient/crud.py
index 73aafc7..4bcf1bd 100644
--- a/cratonclient/crud.py
+++ b/cratonclient/crud.py
@@ -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
diff --git a/cratonclient/formatters/base.py b/cratonclient/formatters/base.py
index 62dec88..acc702d 100644
--- a/cratonclient/formatters/base.py
+++ b/cratonclient/formatters/base.py
@@ -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
 
diff --git a/cratonclient/shell/v1/hosts_shell.py b/cratonclient/shell/v1/hosts_shell.py
index b804bd4..cdbb185 100644
--- a/cratonclient/shell/v1/hosts_shell.py
+++ b/cratonclient/shell/v1/hosts_shell.py
@@ -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'))
diff --git a/cratonclient/tests/integration/shell/v1/test_hosts_shell.py b/cratonclient/tests/integration/shell/v1/test_hosts_shell.py
index 9b932d5..c97e5ef 100644
--- a/cratonclient/tests/integration/shell/v1/test_hosts_shell.py
+++ b/cratonclient/tests/integration/shell/v1/test_hosts_shell.py
@@ -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'}
+        )
diff --git a/cratonclient/tests/integration/test_crud.py b/cratonclient/tests/integration/test_crud.py
index 491f5f2..6c583ad 100644
--- a/cratonclient/tests/integration/test_crud.py
+++ b/cratonclient/tests/integration/test_crud.py
@@ -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'},
         )
diff --git a/cratonclient/tests/unit/common/__init__.py b/cratonclient/tests/unit/common/__init__.py
new file mode 100644
index 0000000..4488217
--- /dev/null
+++ b/cratonclient/tests/unit/common/__init__.py
@@ -0,0 +1 @@
+"""Unit tests for cratonclient.common submodules."""
diff --git a/cratonclient/tests/unit/common/test_cliutils.py b/cratonclient/tests/unit/common/test_cliutils.py
new file mode 100644
index 0000000..92dcad1
--- /dev/null
+++ b/cratonclient/tests/unit/common/test_cliutils.py
@@ -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)
diff --git a/cratonclient/tests/unit/formatters/test_base_formatter.py b/cratonclient/tests/unit/formatters/test_base_formatter.py
index 2dc56d9..1cd58df 100644
--- a/cratonclient/tests/unit/formatters/test_base_formatter.py
+++ b/cratonclient/tests/unit/formatters/test_base_formatter.py
@@ -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)
diff --git a/cratonclient/tests/unit/test_crud.py b/cratonclient/tests/unit/test_crud.py
index e3ef84b..a125204 100644
--- a/cratonclient/tests/unit/test_crud.py
+++ b/cratonclient/tests/unit/test_crud.py
@@ -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)
diff --git a/cratonclient/tests/unit/v1/test_clouds.py b/cratonclient/tests/unit/v1/test_clouds.py
index 6e97fa2..3a6b1e1 100644
--- a/cratonclient/tests/unit/v1/test_clouds.py
+++ b/cratonclient/tests/unit/v1/test_clouds.py
@@ -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)
 
 
diff --git a/cratonclient/tests/unit/v1/test_regions.py b/cratonclient/tests/unit/v1/test_regions.py
index 969f596..184b823 100644
--- a/cratonclient/tests/unit/v1/test_regions.py
+++ b/cratonclient/tests/unit/v1/test_regions.py
@@ -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)
 
 
diff --git a/cratonclient/tests/unit/v1/test_variables.py b/cratonclient/tests/unit/v1/test_variables.py
new file mode 100644
index 0000000..7985189
--- /dev/null
+++ b/cratonclient/tests/unit/v1/test_variables.py
@@ -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())
diff --git a/cratonclient/v1/hosts.py b/cratonclient/v1/hosts.py
index bd135c3..136f223 100644
--- a/cratonclient/v1/hosts.py
+++ b/cratonclient/v1/hosts.py
@@ -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):
diff --git a/cratonclient/v1/variables.py b/cratonclient/v1/variables.py
new file mode 100644
index 0000000..f08eb1f
--- /dev/null
+++ b/cratonclient/v1/variables.py
@@ -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)