[lxc-devel] [pylxd/master] !!! WIP - Implementation of storage pools for pylxd

ajkavanagh on Github lxc-bot at linuxcontainers.org
Wed Jun 27 14:44:58 UTC 2018


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 666 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20180627/bc987ade/attachment.bin>
-------------- next part --------------
From d589b6b65823d9116fc0f61e209af236c145bc4e Mon Sep 17 00:00:00 2001
From: Alex Kavanagh <alex at ajkavanagh.co.uk>
Date: Wed, 27 Jun 2018 15:38:24 +0100
Subject: [PATCH] Implementation of storage pools for pylxd

This fully completes the 'storage' and 'resources' api_extensions of lxd
and also introduces a patch method into the Client class and extends the
put and patch methods to the storage volume (as they'll be needed to do
sane modifications).

This commit doesn't have the documentation changes, nor the functional
testing changes -- it does have the unit tests.
---
 doc/source/networks.rst              |   2 +-
 integration/test_networks.py         |   2 +-
 pylxd/client.py                      |  32 ++
 pylxd/exceptions.py                  |  15 +-
 pylxd/models/__init__.py             |   6 +-
 pylxd/models/_model.py               |  93 ++++++
 pylxd/models/container.py            |   6 +-
 pylxd/models/network.py              |  37 +--
 pylxd/models/storage_pool.py         | 564 +++++++++++++++++++++++++++++++++--
 pylxd/tests/mock_lxd.py              | 102 +++++++
 pylxd/tests/models/test_container.py |   2 +-
 pylxd/tests/models/test_model.py     | 102 ++++++-
 pylxd/tests/models/test_network.py   |  80 ++---
 pylxd/tests/models/test_storage.py   | 208 ++++++++++++-
 pylxd/tests/test_client.py           |  29 ++
 15 files changed, 1152 insertions(+), 128 deletions(-)

diff --git a/doc/source/networks.rst b/doc/source/networks.rst
index 2e259e8..0cab25a 100644
--- a/doc/source/networks.rst
+++ b/doc/source/networks.rst
@@ -5,7 +5,7 @@ Networks
 
 :class:`Network` objects show the current networks available to LXD. Creation
 and / or modification of networks is possible only if 'network' LXD API
-extension is present (see :func:`~Network.network_extension_available`)
+extension is present.
 
 
 Manager methods
diff --git a/integration/test_networks.py b/integration/test_networks.py
index 183488d..fa4ebd8 100644
--- a/integration/test_networks.py
+++ b/integration/test_networks.py
@@ -22,7 +22,7 @@ class NetworkTestCase(IntegrationTestCase):
     def setUp(self):
         super(NetworkTestCase, self).setUp()
 
-        if not Network.network_extension_available(self.client):
+        if not self.client.has_api_extension('network'):
             self.skipTest('Required LXD API extension not available!')
 
 
diff --git a/pylxd/client.py b/pylxd/client.py
index 22d5902..9511e82 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -164,6 +164,16 @@ def put(self, *args, **kwargs):
         self._assert_response(response, allowed_status_codes=(200, 202))
         return response
 
+    def patch(self, *args, **kwargs):
+        """Perform an HTTP PATCH."""
+        kwargs['timeout'] = kwargs.get('timeout', self._timeout)
+        print(self._api_endpoint, args, kwargs)
+        response = self.session.patch(self._api_endpoint, *args, **kwargs)
+        # remove debug
+        print(response.json())
+        self._assert_response(response, allowed_status_codes=(200, 202))
+        return response
+
     def delete(self, *args, **kwargs):
         """Perform an HTTP delete."""
         kwargs['timeout'] = kwargs.get('timeout', self._timeout)
@@ -301,6 +311,28 @@ def __init__(
     def trusted(self):
         return self.host_info['auth'] == 'trusted'
 
+    def has_api_extension(self, name):
+        """Return True if the `name` api extension exists.
+
+        :param name: the api_extension to look for.
+        :type name: str
+        :returns: True if extension exists
+        :rtype: bool
+        """
+        return name in self.host_info['api_extensions']
+
+    def assert_has_api_extension(self, name):
+        """Asserts that the `name` api_extension exists.
+        If not, then is raises the LXDAPIExtensionNotAvailable error.
+
+        :param name: the api_extension to test for
+        :type name: str
+        :returns: None
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable`
+        """
+        if not self.has_api_extension(name):
+            raise exceptions.LXDAPIExtensionNotAvailable(name)
+
     def authenticate(self, password):
         if self.trusted:
             return
diff --git a/pylxd/exceptions.py b/pylxd/exceptions.py
index 008fe59..38a91fa 100644
--- a/pylxd/exceptions.py
+++ b/pylxd/exceptions.py
@@ -19,7 +19,10 @@ def __init__(self, response):
 
     def __str__(self):
         if self.response.status_code == 200:  # Operation failure
-            return self.response.json()['metadata']['err']
+            try:
+                return self.response.json()['metadata']['err']
+            except (ValueError, KeyError):
+                pass
 
         try:
             data = self.response.json()
@@ -37,6 +40,16 @@ class LXDAPIExtensionNotAvailable(Exception):
     """An exception raised when requested LXD API Extension is not present
     on current host."""
 
+    def __init__(self, name, *args, **kwargs):
+        """Custom exception handling of the message is to convert the name in
+        to a friendly error string.
+
+        :param name: the api_extension that was needed.
+        :type name: str
+        """
+        super(LXDAPIExtensionNotAvailable, self).__init__(
+            "LXD API extension '{}' is not available".format(name),
+            *args, **kwargs)
 
 class ClientConnectionFailed(Exception):
     """An exception raised when the Client connection fails."""
diff --git a/pylxd/models/__init__.py b/pylxd/models/__init__.py
index 5f3a42b..74b82ad 100644
--- a/pylxd/models/__init__.py
+++ b/pylxd/models/__init__.py
@@ -4,4 +4,8 @@
 from pylxd.models.network import Network  # NOQA
 from pylxd.models.operation import Operation  # NOQA
 from pylxd.models.profile import Profile  # NOQA
-from pylxd.models.storage_pool import StoragePool  # NOQA
+from pylxd.models.storage_pool import (  # NOQA
+    StoragePool,  # NOQA
+    StorageResources,  # NOQA
+    StorageVolume,  # NOQA
+)  # NOQA
diff --git a/pylxd/models/_model.py b/pylxd/models/_model.py
index 0d71b17..83fd067 100644
--- a/pylxd/models/_model.py
+++ b/pylxd/models/_model.py
@@ -214,3 +214,96 @@ def marshall(self, skip_readonly=True):
                 if val is not MISSING:
                     marshalled[key] = val
         return marshalled
+
+    def put(self, put_object, wait=False):
+        """Access the PUT method directly for the object.
+
+        This is to bypass the `save` method, and introduce a slightly saner
+        approach of thinking about immuatable objects coming *from* the lXD
+        server, and sending back PUTs and PATCHes.
+
+        This method allows arbitrary puts to be attempted on the object (thus
+        by passing the API attributes), but syncs the object overwriting any
+        changes that may have been made to it.For a raw object return, see
+        `raw_put`, which does not modify the object, and returns nothing.
+
+        The `put_object` is the dictionary keys in the json object that is sent
+        to the server for the API endpoint for the model.
+
+        :param wait: If wait is True, then wait here until the operation
+            completes.
+        :type wait: bool
+        :param put_object: jsonable dictionary to use as the PUT json object.
+        :type put_object: dict
+        :raises: :class:`pylxd.exception.LXDAPIException` on error
+        """
+        self.raw_put(put_object, wait)
+        self.sync(rollback=True)
+
+    def raw_put(self, put_object, wait=False):
+        """Access the PUT method on the object direct, but with NO sync back.
+
+        This accesses the PUT method for the object, but uses the `put_object`
+        param to send as the JSON object.  It does NOT update the object
+        afterwards, so it is effectively stale.  This is to allow a PUT when
+        the object is no longer needed as it avoids another GET on the object.
+
+        :param wait: If wait is True, then wait here until the operation
+            completes.
+        :type wait: bool
+        :param put_object: jsonable dictionary to use as the PUT json object.
+        :type put_object: dict
+        :raises: :class:`pylxd.exception.LXDAPIException` on error
+        """
+        response = self.api.put(json=put_object)
+
+        if response.json()['type'] == 'async' and wait:
+            self.client.operations.wait_for_operation(
+                response.json()['operation'])
+
+    def patch(self, patch_object, wait=False):
+        """Access the PATCH method directly for the object.
+
+        This is to bypass the `save` method, and introduce a slightly saner
+        approach of thinking about immuatable objects coming *from* the lXD
+        server, and sending back PUTs and PATCHes.
+
+        This method allows arbitrary patches to be attempted on the object
+        (thus by passing the API attributes), but syncs the object overwriting
+        any changes that may have been made to it.  For a raw object return,
+        see `raw_patch`, which does not modify the object, and returns nothing.
+
+        The `patch_object` is the dictionary keys in the json object that is
+        sent to the server for the API endpoint for the model.
+
+        :param wait: If wait is True, then wait here until the operation
+            completes.
+        :type wait: bool
+        :param patch_object: jsonable dictionary to use as the PUT json object.
+        :type patch_object: dict
+        :raises: :class:`pylxd.exception.LXDAPIException` on error
+        """
+        self.raw_patch(patch_object, wait)
+        self.sync(rollback=True)
+
+    def raw_patch(self, patch_object, wait=False):
+        """Access the PATCH method on the object direct, but with NO sync back.
+
+        This accesses the PATCH method for the object, but uses the
+        `patch_object` param to send as the JSON object.  It does NOT update
+        the object afterwards, so it is effectively stale.  This is to allow a
+        PATCH when the object is no longer needed as it avoids another GET on
+        the object.
+
+        :param wait: If wait is True, then wait here until the operation
+            completes.
+        :type wait: bool
+        :param patch_object: jsonable dictionary to use as the PUT json object.
+        :type patch_object: dict
+        :raises: :class:`pylxd.exception.LXDAPIException` on error
+        """
+        response = self.api.patch(json=patch_object)
+
+        if response.json()['type'] == 'async' and wait:
+            self.client.operations.wait_for_operation(
+                response.json()['operation'])
diff --git a/pylxd/models/container.py b/pylxd/models/container.py
index f55e53e..d3b6ced 100644
--- a/pylxd/models/container.py
+++ b/pylxd/models/container.py
@@ -138,12 +138,10 @@ def delete_available(self):
             """File deletion is an extension API and may not be available.
             https://github.com/lxc/lxd/blob/master/doc/api-extensions.md#file_delete
             """
-            return u'file_delete' in self._client.host_info['api_extensions']
+            return self._client.has_api_extension('file_delete')
 
         def delete(self, filepath):
-            if not self.delete_available():
-                raise ValueError(
-                    'File Deletion is not available for this host')
+            self._client.assert_has_api_extension('file_delete')
             response = self._client.api.containers[
                 self._container.name].files.delete(
                 params={'path': filepath})
diff --git a/pylxd/models/network.py b/pylxd/models/network.py
index 8e5d9d3..00114b5 100644
--- a/pylxd/models/network.py
+++ b/pylxd/models/network.py
@@ -13,7 +13,6 @@
 #    under the License.
 import json
 
-from pylxd.exceptions import LXDAPIExtensionNotAvailable
 from pylxd.models import _model as model
 
 
@@ -94,8 +93,7 @@ def create(cls, client, name, description=None, type=None, config=None):
         :param config: additional configuration
         :type config: dict
         """
-
-        cls._check_network_api_extension(client)
+        client.assert_has_api_extension('network')
 
         network = {'name': name}
         if description is not None:
@@ -116,35 +114,18 @@ def rename(self, new_name):
         :return: Renamed network instance
         :rtype: :class:`Network`
         """
-
-        self._check_network_api_extension(self.client)
-
+        self.client.assert_has_api_extension('network')
         self.client.api.networks.post(json={'name': new_name})
         return Network.get(self.client, new_name)
 
     def save(self, *args, **kwargs):
-        self._check_network_api_extension(self.client)
+        self.client.assert_has_api_extension('network')
         super(Network, self).save(*args, **kwargs)
 
     @property
     def api(self):
         return self.client.api.networks[self.name]
 
-    @staticmethod
-    def network_extension_available(client):
-        """
-        Network operations is an extension API and may not be available.
-
-        https://github.com/lxc/lxd/blob/master/doc/api-extensions.md#network
-
-        :param client: client instance
-        :type client: :class:`~pylxd.client.Client`
-        :returns: `True` if network API extension is available, `False`
-         otherwise.
-        :rtype: `bool`
-        """
-        return u'network' in client.host_info['api_extensions']
-
     def __str__(self):
         return json.dumps(self.marshall(skip_readonly=False), indent=2)
 
@@ -156,15 +137,3 @@ def __repr__(self):
 
         return '{}({})'.format(self.__class__.__name__,
                                ', '.join(sorted(attrs)))
-
-    @staticmethod
-    def _check_network_api_extension(client):
-        """
-        :param client: client instance
-        :type client: :class:`~pylxd.client.Client`
-        :raises: `exceptions.LXDAPIExtensionNotAvailable` when network
-         operations API extension is not available.
-        """
-        if not Network.network_extension_available(client):
-            raise LXDAPIExtensionNotAvailable(
-                'Network creation is not available for this host')
diff --git a/pylxd/models/storage_pool.py b/pylxd/models/storage_pool.py
index caf723e..0685080 100644
--- a/pylxd/models/storage_pool.py
+++ b/pylxd/models/storage_pool.py
@@ -12,24 +12,50 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 from pylxd.models import _model as model
+from pylxd import managers
 
 
 class StoragePool(model.Model):
-    """A LXD storage_pool.
+    """An LXD storage_pool.
 
     This corresponds to the LXD endpoint at
     /1.0/storage-pools
+
+    api_extension: 'storage'
     """
-    name = model.Attribute()
-    driver = model.Attribute()
+    name = model.Attribute(readonly=True)
+    driver = model.Attribute(readonly=True)
     description = model.Attribute()
-    used_by = model.Attribute()
+    used_by = model.Attribute(readonly=True)
     config = model.Attribute()
-    managed = model.Attribute()
+    managed = model.Attribute(readonly=True)
+
+    resources = model.Manager()
+    volumes = model.Manager()
+
+    def __init__(self, *args, **kwargs):
+        super(StoragePool, self).__init__(*args, **kwargs)
+
+        self.resources = StorageResourcesManager(self)
+        self.volumes = StorageVolumeManager(self)
 
     @classmethod
     def get(cls, client, name):
-        """Get a storage_pool by name."""
+        """Get a storage_pool by name.
+
+        Implements GET /1.0/storage-pools/<name>
+
+        :param client: The pylxd client object
+        :type client: :class:`pylxd.client.Client`
+        :param name: the name of the storage pool to get
+        :type name: str
+        :returns: a storage pool if successful, raises NotFound if not found
+        :rtype: :class:`pylxd.models.storage_pool.StoragePool`
+        :raises: :class:`pylxd.exceptions.NotFound`
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        """
+        client.assert_has_api_extension('storage')
         response = client.api.storage_pools[name].get()
 
         storage_pool = cls(client, **response.json()['metadata'])
@@ -37,7 +63,22 @@ def get(cls, client, name):
 
     @classmethod
     def all(cls, client):
-        """Get all storage_pools."""
+        """Get all storage_pools.
+
+        Implements GET /1.0/storage-pools
+
+        Note that the returned list is 'sparse' in that only the name of the
+        pool is populated.  If any of the attributes are used, then the `sync`
+        function is called to populate the object fully.
+
+        :param client: The pylxd client object
+        :type client: :class:`pylxd.client.Client`
+        :returns: a storage pool if successful, raises NotFound if not found
+        :rtype: [:class:`pylxd.models.storage_pool.StoragePool`]
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        """
+        client.assert_has_api_extension('storage')
         response = client.api.storage_pools.get()
 
         storage_pools = []
@@ -47,30 +88,519 @@ def all(cls, client):
         return storage_pools
 
     @classmethod
-    def create(cls, client, config):
-        """Create a storage_pool from config."""
-        client.api.storage_pools.post(json=config)
+    def create(cls, client, definition):
+        """Create a storage_pool from config.
+
+        Implements POST /1.0/storage-pools
+
+        The `definition` parameter defines what the storage pool will be.  An
+        example config for the zfs driver is:
+
+            {
+                "config": {
+                    "size": "10GB"
+                },
+                "driver": "zfs",
+                "name": "pool1"
+            }
+
+        Note that **all** fields in the `definition` parameter are strings.
+
+        For further details on the storage pool types see:
+        https://lxd.readthedocs.io/en/latest/storage/
+
+        The function returns the a `StoragePool` instance, if it is
+        successfully created, otherwise an Exception is raised.
+
+        :param client: The pylxd client object
+        :type client: :class:`pylxd.client.Client`
+        :param definition: the fields to pass to the LXD API endpoint
+        :type definition: dict
+        :returns: a storage pool if successful, raises NotFound if not found
+        :rtype: :class:`pylxd.models.storage_pool.StoragePool`
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            couldn't be created.
+        """
+        client.assert_has_api_extension('storage')
+        client.api.storage_pools.post(json=definition)
 
-        storage_pool = cls.get(client, config['name'])
+        storage_pool = cls.get(client, definition['name'])
         return storage_pool
 
     @classmethod
     def exists(cls, client, name):
-        """Determine whether a storage pool exists."""
+        """Determine whether a storage pool exists.
+
+        A convenience method to determine a pool exists.  However, it is better
+        to try to fetch it and catch the :class:`pylxd.exceptions.NotFound`
+        exception, as otherwise the calling code is like to fetch the pool
+        twice.  Only use this if the calling code doesn't *need* the actual
+        storage pool information.
+
+        :param client: The pylxd client object
+        :type client: :class:`pylxd.client.Client`
+        :param name: the name of the storage pool to get
+        :type name: str
+        :returns: True if the storage pool exists, False if it doesn't.
+        :rtype: bool
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        """
         try:
-            client.storage_pools.get(name)
+            cls.get(client, name)
             return True
         except cls.NotFound:
             return False
 
     @property
     def api(self):
+        """Provides an object with the endpoint:
+
+        /1.0/storage-pools/<self.name>
+
+        Used internally to construct endpoints.
+
+        :returns: an API node with the named endpoint
+        :rtype: :class:`pylxd.client._APINode`
+        """
         return self.client.api.storage_pools[self.name]
 
     def save(self, wait=False):
-        """Save is not available for storage_pools."""
-        raise NotImplementedError('save is not implemented')
+        """Save the model using PUT back to the LXD server.
+
+        Implements PUT /1.0/storage-pools/<self.name> *automagically*
+
+        The fields affected are: `description` and `config`.  Note that they
+        are replaced in their *entirety*.  If finer grained control is
+        required, please use the
+        :meth:`~pylxd.models.storage_pool.StoragePool.patch` method directly.
+
+        Updating a storage pool may fail if the config is not acceptable to
+        LXD. An :class:`~pylxd.exceptions.LXDAPIException` will be generated in
+        that case.
+
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            can't be deleted.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StoragePool, self).save(wait=wait)
+
+    def delete(self):
+        """Delete the storage pool.
+
+        Implements DELETE /1.0/storage-pools/<self.name>
+
+        Deleting a storage pool may fail if it is being used.  See the LXD
+        documentation for further details.
+
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            can't be deleted.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StoragePool, self).delete()
+
+    def put(self, put_object, wait=False):
+        """Put the storage pool.
+
+        Implements PUT /1.0/storage-pools/<self.name>
+
+        Putting to a storage pool may fail if the new configuration is
+        incompatible with the pool.  See the LXD documentation for further
+        details.
+
+        Note that the object is refreshed with a `sync` if the PUT is
+        successful.  If this is *not* desired, then the raw API on the client
+        should be used.
+
+        :param put_object: A dictionary.  The most useful key will be the
+            `config` key.
+        :type put_object: dict
+        :param wait: Whether to wait for async operations to complete.
+        :type wait: bool
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            can't be modified.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StoragePool, self).put(put_object, wait)
+
+    def patch(self, patch_object, wait=False):
+        """Patch the storage pool.
+
+        Implements PATCH /1.0/storage-pools/<self.name>
+
+        Patching the object allows for more fine grained changes to the config.
+        The object is refreshed if the PATCH is successful.  If this is *not*
+        required, then use the client api directly.
+
+        :param patch_object: A dictionary.  The most useful key will be the
+            `config` key.
+        :type patch_object: dict
+        :param wait: Whether to wait for async operations to complete.
+        :type wait: bool
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            can't be modified.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StoragePool, self).patch(patch_object, wait)
+
+
+class StorageResourcesManager(managers.BaseManager):
+    manager_for = 'pylxd.models.StorageResources'
+
+
+class StorageResources(model.Model):
+    """An LXD Storage Resources model.
+
+    This corresponds to the LXD endpoing at
+    /1.0/storage-pools/<pool>/resources
+
+    At present, this is read-only model.
+
+    api_extension: 'resources'
+    """
+    space = model.Attribute(readonly=True)
+    inodes = model.Attribute(readonly=True)
+
+    @classmethod
+    def get(cls, storage_pool):
+        """Get a storage_pool resource for a named pool
+
+        Implements GET /1.0/storage-pools/<pool>/resources
+
+        Needs the 'resources' api extension in the LXD server.
+
+        :param storage_pool: a storage pool object on which to fetch resources
+        :type storage_pool: :class:`pylxd.models.storage_pool.StoragePool`
+        :returns: A storage resources object
+        :rtype: :class:`pylxd.models.storage_pool.StorageResources`
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'resources' api extension is missing.
+        """
+        storage_pool.client.assert_has_api_extension('resources')
+        response = storage_pool.api.resources.get()
+        resources = cls(storage_pool.client, **response.json()['metadata'])
+        return resources
+
+
+class StorageVolumeManager(managers.BaseManager):
+    manager_for = 'pylxd.models.StorageVolume'
+
+
+class StorageVolume(model.Model):
+    """An LXD Storage volume.
+
+    This corresponds to the LXD endpoing at
+    /1.0/storage-pools/<pool>/volumes
+
+    api_extension: 'storage'
+    """
+    name = model.Attribute(readonly=True)
+    type = model.Attribute(readonly=True)
+    description= model.Attribute(readonly=True)
+    config = model.Attribute()
+    used_by = model.Attribute(readonly=True)
+    location = model.Attribute(readonly=True)
+
+    storage_pool = model.Parent()
+
+    @property
+    def api(self):
+        """Provides an object with the endpoint:
+
+        /1.0/storage-pools/<storage_pool.name>/volumes/<self.type>/<self.name>
+
+        Used internally to construct endpoints.
+
+        :returns: an API node with the named endpoint
+        :rtype: :class:`pylxd.client._APINode`
+        """
+        return self.storage_pool.api.volumes[self.type][self.name]
+
+    @classmethod
+    def all(cls, storage_pool):
+        """Get all the volumnes for this storage pool.
+
+        Implements GET /1.0/storage-pools/<name>/volumes
+
+        Volumes returned from this method will only have the name
+        set, as that is the only property returned from LXD. If more
+        information is needed, `StorageVolume.sync` is the method call
+        that should be used.
+
+        Note that the storage volume types are 'container', 'image' and
+        'custom', and these maps to the names 'containers', 'images' and
+        everything else is mapped to 'custom'.
+
+        :param storage_pool: a storage pool object on which to fetch resources
+        :type storage_pool: :class:`pylxd.models.storage_pool.StoragePool`
+        :returns: a list storage volume if successful
+        :rtype: [:class:`pylxd.models.storage_pool.StorageVolume`]
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        """
+        storage_pool.client.assert_has_api_extension('storage')
+        response = storage_pool.api.volumes.get()
+
+        volumes = []
+        for volume in response.json()['metadata']:
+            (_type, name) = volume.split('/')[-2:]
+            # for each type, convert to the string that will work with GET
+            if _type == 'containers':
+                _type = 'container'
+            elif _type == 'images':
+                _type = 'image'
+            else:
+                _type = 'custom'
+            volumes.append(
+                cls(storage_pool.client,
+                    name=name,
+                    type=_type,
+                    storage_pool=storage_pool))
+        return volumes
+
+    @classmethod
+    def get(cls, storage_pool, _type, name):
+        """Get a StorageVolume by type and name.
+
+        Implements GET /1.0/storage-pools/<pool>/volumes/<type>/<name>
+
+        The `_type` param can only (currently) be one of 'container', 'image'
+        or 'custom'.  This was determined by read the LXD source.
+
+        :param storage_pool: a storage pool object on which to fetch resources
+        :type storage_pool: :class:`pylxd.models.storage_pool.StoragePool`
+        :param _type: the type; one of 'container', 'image', 'custom'
+        :type _type: str
+        :param name: the name of the storage volume to get
+        :type name: str
+        :returns: a storage pool if successful, raises NotFound if not found
+        :rtype: :class:`pylxd.models.storage_pool.StorageVolume`
+        :raises: :class:`pylxd.exceptions.NotFound`
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        """
+        storage_pool.client.assert_has_api_extension('storage')
+        response = storage_pool.api.volumes[_type][name].get()
+
+        volume = cls(
+            storage_pool.client, storage_pool=storage_pool,
+            **response.json()['metadata'])
+        return volume
+
+    @classmethod
+    # def create(cls, storage_pool, definition, wait=True, *args):
+    def create(cls, storage_pool, *args, **kwargs):
+        """Create a Storage Volume in the associated storage pool.
+
+        Implements POST /1.0/storage-pools/<pool>/volumes
+
+        See https://github.com/lxc/lxd/blob/master/doc/rest-api.md#post-19 for
+        more details on what the `definition` parameter dictionary should
+        contain for various volume creation.
+
+        Note, it's likely that the only type being created is 'custom'.
+
+        The function signature 'hides' that the first parameter to the function
+        is the definition.  The function should be called as:
+
+        >>> a_storage_pool.volumes.create(definition_dict, wait=<bool>)
+
+        where `definition_dict` is mandatory, and `wait` defaults to True,
+        which makes the default a synchronous function call.
+
+        The definition parameter should contain the 'pool' and this will be
+        added automatically if it is missing.  If this behaviour is not desired
+        then use the `client.api..` directly.
+
+        Note that **all** fields in the `definition` parameter are strings.
+
+        If the caller doesn't wan't to wait for an async operation, then it
+        MUST be passed as a keyword argument, and not as a positional
+        substitute.
+
+        The function returns the a
+        :class:`~pylxd.models.storage_pool.StoragePool` instance, if it is
+        successfully created, otherwise an Exception is raised.
+
+        :param storage_pool: a storage pool object on which to fetch resources
+        :type storage_pool: :class:`pylxd.models.storage_pool.StoragePool`
+        :param definition: the fields to pass to the LXD API endpoint
+        :type definition: dict
+        :param wait: wait until an async action has completed (default True)
+        :type wait: bool
+        :returns: a storage pool volume if successful, raises NotFound if not
+            found
+        :rtype: :class:`pylxd.models.storage_pool.StorageVolume`
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            volume couldn't be created.
+        """
+        # This is really awkward, but the implementation details mean,
+        # depending on how this function is called, we can't know whether the
+        # 2nd positional argument will be definition or a client object. This
+        # is an difficulty with how BaseManager is implemented, and to get the
+        # convenience of being able to call this 'naturally' off of a
+        # storage_pool.  So we have to jump through some hurdles to get the
+        # right positional parameters.
+
+        storage_pool.client.assert_has_api_extension('storage')
+        wait = kwargs.get('wait', True)
+        definition = args[-1]
+        assert isinstance(definition, dict)
+        assert 'type' in definition
+        assert 'name' in definition
+        # override/configure the storage pool in the definition
+        definition['pool'] = storage_pool.name
+        response = storage_pool.api.volumes.post(json=definition)
+
+        if response.json()['type'] == 'async' and wait:
+            self.client.operations.wait_for_operation(
+                response.json()['operation'])
+
+        volume = cls.get(storage_pool,
+                         definition['type'],
+                         definition['name'])
+        return volume
+
+    def rename(self, _input, wait=False):
+        """Rename a storage volume
+
+        This requires api_extension: 'storage_api_volume_rename'.
+
+        Implements: POST /1.0/storage-pools/<pool>/volumes/<type>/<name>
+
+        This operation is either sync or async (when moving to a different
+        pool).
+
+        This command also allows the migration across instances, and therefore
+        it returns the metadata section (as a dictionary) from the call.  The
+        caller should assume that the object is stale and refetch it after any
+        migrations are done.  However, the 'name' attribute of the object is
+        updated for consistency.
+
+        Unlike :meth:`~pylxd.models.storage_pool.StorageVolume.create`, this
+        method does not override any items in the input definition, although it
+        does check that the 'name' and 'pool' parameters are set.
+
+        Please see: https://github.com/lxc/lxd/blob/master/doc/rest-api.md#10storage-poolspoolvolumestypename
+        for more details.
+
+        :param _input: The `input` specification for the rename.
+        :type _input: dict
+        :param wait: Wait for an async operation, if it is async.
+        :type wait: bool
+        :returns:  The dictionary from the metadata section of the response if
+            successful.
+        :rtype: dict[str, str]
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage_api_volume_rename' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            volume couldn't be renamed.
+        """
+        assert isinstance(_input, dict)
+        assert 'name' in _input
+        assert 'pool' in _input
+        response = self.api.post(json=_input)
+
+        response_json = response.json()
+        if wait:
+            self.client.operations.wait_for_operation(
+                response_json['operation'])
+        self.name = _input['name']
+        return response_json['metadata']
+
+    def put(self, put_object, wait=False):
+        """Put the storage volume.
+
+        Implements: PUT /1.0/storage-pools/<pool>/volumes/<type>/<name>
+
+        Note that this is functionality equivalent to
+        :meth:`~pyxld.models.storage_pool.StorageVolume.save` but by using a
+        new object (`put_object`) rather than modifying the object and then
+        calling :meth:`~pyxld.models.storage_pool.StorageVolume.save`.
+
+        Putting to a storage volume may fail if the new configuration is
+        incompatible with the pool.  See the LXD documentation for further
+        details.
+
+        Note that the object is refreshed with a `sync` if the PUT is
+        successful.  If this is *not* desired, then the raw API on the client
+        should be used.
+
+        :param put_object: A dictionary.  The most useful key will be the
+            `config` key.
+        :type put_object: dict
+        :param wait: Whether to wait for async operations to complete.
+        :type wait: bool
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            can't be modified.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StorageVolume, self).put(put_object, wait)
+
+    def patch(self, patch_object, wait=False):
+        """Patch the storage volume.
+
+        Implements: PATCH /1.0/storage-pools/<pool>/volumes/<type>/<name>
+
+        Patching the object allows for more fine grained changes to the config.
+        The object is refreshed if the PATCH is successful.  If this is *not*
+        required, then use the client api directly.
+
+        :param patch_object: A dictionary.  The most useful key will be the
+            `config` key.
+        :type patch_object: dict
+        :param wait: Whether to wait for async operations to complete.
+        :type wait: bool
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage
+            volume can't be modified.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StorageVolume, self).patch(patch_object, wait)
+
+    def save(self, wait=False):
+        """Save the model using PUT back to the LXD server.
+
+        Implements: PUT /1.0/storage-pools/<pool>/volumes/<type>/<name>
+        *automagically*.
+
+        The field affected is `config`.  Note that it is replaced *entirety*.
+        If finer grained control is required, please use the
+        :meth:`~pylxd.models.storage_pool.StorageVolume.patch` method directly.
+
+        Updating a storage volume may fail if the config is not acceptable to
+        LXD. An :class:`~pylxd.exceptions.LXDAPIException` will be generated in
+        that case.
+
+        :param wait: Whether to wait for async operations to complete.
+        :type wait: bool
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage
+            volume can't be deleted.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StorageVolume, self).save(wait=wait)
 
     def delete(self):
-        """Delete is not available for storage_pools."""
-        raise NotImplementedError('delete is not implemented')
+        """Delete the storage pool.
+
+        Implements: DELETE /1.0/storage-pools/<pool>/volumes/<type>/<name>
+
+        Deleting a storage volume may fail if it is being used.  See the LXD
+        documentation for further details.
+
+        :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the
+            'storage' api extension is missing.
+        :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool
+            can't be deleted.
+        """
+        # Note this method exists so that it is documented via sphinx.
+        super(StorageVolume, self).delete()
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index 8e5cb0c..73211ed 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -645,6 +645,108 @@ def snapshot_DELETE(request, context):
         'method': 'POST',
         'url': r'^http://pylxd.test/1.0/storage-pools$',
     },
+    {
+        'json': {'type': 'sync'},
+        'method': 'DELETE',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd$',
+    },
+    {
+        'json': {'type': 'sync'},
+        'method': 'PUT',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd$',
+    },
+    {
+        'json': {'type': 'sync'},
+        'method': 'PATCH',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd$',
+    },
+
+    # Storage Resources
+    {
+        'json': {
+            'type': 'sync',
+            'metadata': {
+                'space': {
+                    'used': 207111192576,
+                    'total': 306027577344
+                },
+                'inodes': {
+                    "used": 3275333,
+                    "total": 18989056
+                }
+            }},
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/resources$',
+    },
+
+    # Storage Volumes
+    {
+        'json': {
+            'type': 'sync',
+            'metadata': [
+                '/1.0/storage-pools/default/volumes/containers/c1',
+                '/1.0/storage-pools/default/volumes/containers/c2',
+                '/1.0/storage-pools/default/volumes/containers/c3',
+                '/1.0/storage-pools/default/volumes/images/i1',
+                '/1.0/storage-pools/default/volumes/images/i2',
+                '/1.0/storage-pools/default/volumes/custom/cu1',
+            ]},
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes$',
+    },
+
+    {
+        'json': {'type': 'sync'},
+        'method': 'POST',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes$',
+    },
+    {
+        'json': {
+            "type": "sync",
+            "status": "Success",
+            "status_code": 200,
+            "error_code": 0,
+            "error": "",
+            "metadata": {
+                "type": "custom",
+                "used_by": [],
+                "name": "cu1",
+                "config": {
+                    "block.filesystem": "ext4",
+                    "block.mount_options": "discard",
+                    "size": "10737418240"
+                }
+            }
+        },
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes/custom/cu1$',
+    },
+    {
+        'json': {
+            "type": "sync",
+            "metadata": {
+                "control": "secret1",
+                "fs": "secret2"
+            },
+        },
+        'method': 'POST',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes/custom/cu1$',
+    },
+    {
+        'json': {'type': 'sync'},
+        'method': 'PUT',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes/custom/cu1$',
+    },
+    {
+        'json': {'type': 'sync'},
+        'method': 'PATCH',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes/custom/cu1$',
+    },
+    {
+        'json': {'type': 'sync'},
+        'method': 'DELETE',
+        'url': r'^http://pylxd.test/1.0/storage-pools/lxd/volumes/custom/cu1$',
+    },
 
     # Profiles
     {
diff --git a/pylxd/tests/models/test_container.py b/pylxd/tests/models/test_container.py
index 8434723..b689387 100644
--- a/pylxd/tests/models/test_container.py
+++ b/pylxd/tests/models/test_container.py
@@ -444,7 +444,7 @@ def test_put_delete(self):
         """A file is put on the container and then deleted"""
         # we are mocked, so delete should initially not be available
         self.assertEqual(False, self.container.files.delete_available())
-        self.assertRaises(ValueError,
+        self.assertRaises(exceptions.LXDAPIExtensionNotAvailable,
                           self.container.files.delete, '/some/file')
         # Now insert delete
         self.add_rule({
diff --git a/pylxd/tests/models/test_model.py b/pylxd/tests/models/test_model.py
index ca5f1b7..021c3e3 100644
--- a/pylxd/tests/models/test_model.py
+++ b/pylxd/tests/models/test_model.py
@@ -49,7 +49,15 @@ def setUp(self):
                 'type': 'sync',
                 'metadata': {}
             },
-            'method': 'PUT',
+            'method': 'put',
+            'url': r'^http://pylxd.test/1.0/items/an-item',
+        })
+        self.add_rule({
+            'json': {
+                'type': 'sync',
+                'metadata': {}
+            },
+            'method': 'patch',
             'url': r'^http://pylxd.test/1.0/items/an-item',
         })
         self.add_rule({
@@ -179,3 +187,95 @@ def test_save(self):
         item.save()
 
         self.assertFalse(item.dirty)
+
+    def test_put(self):
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+
+        item.put({'age': 69})
+
+        # should sync back to 1000
+        self.assertEqual(item.age, 1000)
+
+    def test_raw_put(self):
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+
+        item.age = 55
+        item.raw_put({'age': 69})
+
+        # should sync NOT back to 1000
+        self.assertEqual(item.age, 55)
+
+    def test_put_raw_async(self):
+        self.add_rule({
+            'json': {
+                'type': 'async',
+                'metadata': {},
+                'operation': "/1.0/items/123456789",
+            },
+            'status_code': 202,
+            'method': 'put',
+            'url': r'^http://pylxd.test/1.0/items/an-item$',
+        })
+        self.add_rule({
+            'json': {
+                'status': 'Running',
+                'status_code': 103,
+                'type': 'sync',
+                'metadata': {
+                    'id': '123456789',
+                    'secret': "some-long-string-of-digits",
+                },
+            },
+            'method': 'get',
+            'url': r'^http://pylxd.test/1.0/operations/123456789$',
+        })
+        self.add_rule({
+            'json': {
+                'type': 'sync',
+            },
+            'method': 'get',
+            'url': r'^http://pylxd.test/1.0/operations/123456789/wait$',
+        })
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+        item.put({'age': 69}, wait=True)
+
+    def test_patch(self):
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+
+        item.patch({'age': 69})
+        # should sync back to 1000
+        self.assertEqual(item.age, 1000)
+
+    def test_patch_raw_async(self):
+        self.add_rule({
+            'json': {
+                'type': 'async',
+                'metadata': {},
+                'operation': "/1.0/items/123456789",
+            },
+            'status_code': 202,
+            'method': 'put',
+            'url': r'^http://pylxd.test/1.0/items/an-item$',
+        })
+        self.add_rule({
+            'json': {
+                'status': 'Running',
+                'status_code': 103,
+                'type': 'sync',
+                'metadata': {
+                    'id': '123456789',
+                    'secret': "some-long-string-of-digits",
+                },
+            },
+            'method': 'get',
+            'url': r'^http://pylxd.test/1.0/operations/123456789$',
+        })
+        self.add_rule({
+            'json': {
+                'type': 'sync',
+            },
+            'method': 'get',
+            'url': r'^http://pylxd.test/1.0/operations/123456789/wait$',
+        })
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+        item.patch({'age': 69}, wait=True)
diff --git a/pylxd/tests/models/test_network.py b/pylxd/tests/models/test_network.py
index 026a670..d8512f8 100644
--- a/pylxd/tests/models/test_network.py
+++ b/pylxd/tests/models/test_network.py
@@ -101,18 +101,15 @@ def not_found(_, context):
         self.assertFalse(models.Network.exists(self.client, name))
 
     def test_all(self):
-        """A list of all networks are returned."""
         networks = models.Network.all(self.client)
 
         self.assertEqual(2, len(networks))
 
-    @mock.patch('pylxd.models.Network.network_extension_available',
-                return_value=True)
-    def test_create_with_parameters(self, _):
-        """A new network is created."""
-        network = models.Network.create(
-            self.client, name='eth1', config={}, type='bridge',
-            description='Network description')
+    def test_create_with_parameters(self):
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            network = models.Network.create(
+                self.client, name='eth1', config={}, type='bridge',
+                description='Network description')
 
         self.assertIsInstance(network, models.Network)
         self.assertEqual('eth1', network.name)
@@ -120,44 +117,35 @@ def test_create_with_parameters(self, _):
         self.assertEqual('bridge', network.type)
         self.assertTrue(network.managed)
 
-    @mock.patch('pylxd.models.Network.network_extension_available',
-                return_value=True)
-    def test_create_default(self, _):
-        """A new network is created with default parameters."""
-        network = models.Network.create(self.client, 'eth1')
+    def test_create_default(self):
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            network = models.Network.create(self.client, 'eth1')
 
         self.assertIsInstance(network, models.Network)
         self.assertEqual('eth1', network.name)
         self.assertEqual('bridge', network.type)
         self.assertTrue(network.managed)
 
-    @mock.patch('pylxd.models.Network.network_extension_available',
-                return_value=False)
-    def test_create_api_not_available(self, _):
-        """A new network is not created because API extension is not
-        available."""
+    def test_create_api_not_available(self):
+        # Note, by default with the tests, no 'network' extension is available.
         with self.assertRaises(LXDAPIExtensionNotAvailable):
             models.Network.create(
                 self.client, name='eth1', config={}, type='bridge',
                 description='Network description')
 
-    @mock.patch('pylxd.models.Network.network_extension_available',
-                return_value=True)
-    def test_rename(self, _):
-        """A network is renamed."""
-        network = models.Network.get(self.client, 'eth0')
-
-        renamed_network = network.rename('eth2')
+    def test_rename(self):
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            network = models.Network.get(self.client, 'eth0')
+            renamed_network = network.rename('eth2')
 
         self.assertEqual('eth2', renamed_network.name)
 
-    @mock.patch('pylxd.models.Network.network_extension_available',
-                return_value=True)
-    def test_update(self, _):
+    def test_update(self):
         """A network is updated."""
-        network = models.Network.get(self.client, 'eth0')
-        network.config = {}
-        network.save()
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            network = models.Network.get(self.client, 'eth0')
+            network.config = {}
+            network.save()
         self.assertEqual({}, network.config)
 
     def test_fetch(self):
@@ -239,33 +227,3 @@ def test_repr(self):
             'Network(config={"ipv4.address": "10.80.100.1/24", "ipv4.nat": '
             '"true", "ipv6.address": "none", "ipv6.nat": "false"}, '
             'description="Network description", name="eth0", type="bridge")')
-
-    def test_check_network_api_extension(self):
-        """`Network.network_extension_available` should return True or False
-        depending on presence of 'network' LXD API extension."""
-
-        # we are mocked, so this API extension should initially not be
-        # available
-        self.assertEqual(
-            False, models.Network.network_extension_available(self.client))
-
-        # Now insert extension
-        rule = {
-            'text': json.dumps({
-                'type': 'sync',
-                'metadata': {'auth': 'trusted',
-                             'environment': {
-                                 'certificate': 'an-pem-cert',
-                             },
-                             'api_extensions': ['network']
-                             }}),
-            'method': 'GET',
-            'url': r'^http://pylxd.test/1.0$',
-        }
-        self.add_rule(rule)
-
-        # Update hostinfo
-        self.client.host_info = self.client.api.get().json()['metadata']
-
-        self.assertEqual(
-            True, models.Network.network_extension_available(self.client))
diff --git a/pylxd/tests/models/test_storage.py b/pylxd/tests/models/test_storage.py
index dfbd96a..f65e5f0 100644
--- a/pylxd/tests/models/test_storage.py
+++ b/pylxd/tests/models/test_storage.py
@@ -13,23 +13,57 @@
 #    under the License.
 import json
 
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+from pylxd import exceptions
 from pylxd import models
 from pylxd.tests import testing
 
 
+def add_api_extension_helper(obj, extensions):
+    obj.add_rule({
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': {'auth': 'trusted',
+                         'environment': {
+                             'certificate': 'an-pem-cert',
+                             },
+                         'api_extensions': extensions
+                         }}),
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0$',
+    })
+    # Update hostinfo
+    obj.client.host_info = obj.client.api.get().json()['metadata']
+
+
 class TestStoragePool(testing.PyLXDTestCase):
     """Tests for pylxd.models.StoragePool."""
 
     def test_all(self):
         """A list of all storage_pools are returned."""
-        storage_pools = models.StoragePool.all(self.client)
+        # first assert that the lxd storage requires 'storage' api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            models.StoragePool.all(self.client)
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
 
+        storage_pools = models.StoragePool.all(self.client)
         self.assertEqual(1, len(storage_pools))
 
     def test_get(self):
         """Return a container."""
         name = 'lxd'
 
+        # first assert that the lxd storage requires 'storage' api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            models.StoragePool.get(self.client, name)
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
+
         an_storage_pool = models.StoragePool.get(self.client, name)
 
         self.assertEqual(name, an_storage_pool.name)
@@ -44,6 +78,12 @@ def test_create(self):
         """A new storage pool is created."""
         config = {"config": {}, "driver": "zfs", "name": "lxd"}
 
+        # first assert that the lxd storage requires 'storage' api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            models.StoragePool.create(self.client, config)
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
+
         an_storage_pool = models.StoragePool.create(self.client, config)
 
         self.assertEqual(config['name'], an_storage_pool.name)
@@ -52,6 +92,12 @@ def test_exists(self):
         """A storage pool exists."""
         name = 'lxd'
 
+        # first assert that the lxd storage requires 'storage' api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            models.StoragePool.exists(self.client, name)
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
+
         self.assertTrue(models.StoragePool.exists(self.client, name))
 
     def test_not_exists(self):
@@ -70,18 +116,168 @@ def not_found(request, context):
 
         name = 'an-missing-storage-pool'
 
-        self.assertFalse(models.StoragePool.exists(self.client, name))
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            self.assertFalse(models.StoragePool.exists(self.client, name))
 
     def test_delete(self):
-        """delete is not implemented in storage_pools."""
+        """A storage pool can be deleted"""
         an_storage_pool = models.StoragePool(self.client, name='lxd')
 
-        with self.assertRaises(NotImplementedError):
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
             an_storage_pool.delete()
 
     def test_save(self):
-        """save is not implemented in storage_pools."""
+        """A storage pool can be saved"""
         an_storage_pool = models.StoragePool(self.client, name='lxd')
+        an_storage_pool.config = {'some': 'value'}
 
-        with self.assertRaises(NotImplementedError):
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
             an_storage_pool.save()
+
+    def test_put(self):
+        """A storage pool can be PUT to"""
+        an_storage_pool = models.StoragePool(self.client, name='lxd')
+        put_object = {'some': 'value'}
+
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            an_storage_pool.put(put_object)
+
+    def test_patch(self):
+        """A storage pool can be PATCHed"""
+        an_storage_pool = models.StoragePool(self.client, name='lxd')
+        patch_object = {'some': 'value'}
+
+        with mock.patch.object(self.client, 'assert_has_api_extension'):
+            an_storage_pool.patch(patch_object)
+
+
+class TestStorageResources(testing.PyLXDTestCase):
+    """Tests for pylxd.models.StorageResources."""
+
+    def test_get(self):
+        an_storage_pool = models.StoragePool(self.client, name='lxd')
+
+        # first assert that the lxd storage resource requires 'resources'
+        # api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            an_storage_pool.resources.get()
+
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['resources'])
+        resources = an_storage_pool.resources.get()
+
+        self.assertEqual(resources.space['used'], 207111192576)
+        self.assertEqual(resources.space['total'], 306027577344)
+        self.assertEqual(resources.inodes['used'], 3275333)
+        self.assertEqual(resources.inodes['total'], 18989056)
+
+
+class TestStorageVolume(testing.PyLXDTestCase):
+    """Tests for pylxd.models.StorageVolume."""
+
+
+    def test_all(self):
+        an_storage_pool = models.StoragePool(self.client, name='lxd')
+        # first assert that the lxd storage resource requires 'storage'
+        # api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            an_storage_pool.volumes.all()
+
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
+
+        volumes = an_storage_pool.volumes.all()
+
+        # assert that we decoded stuff reasonably well.
+        self.assertEqual(len(volumes), 6)
+        self.assertEqual(volumes[0].type, 'container')
+        self.assertEqual(volumes[0].name, 'c1')
+        self.assertEqual(volumes[3].type, 'image')
+        self.assertEqual(volumes[3].name, 'i1')
+        self.assertEqual(volumes[5].type, 'custom')
+        self.assertEqual(volumes[5].name, 'cu1')
+
+    def test_get(self):
+        an_storage_pool = models.StoragePool(self.client, name='lxd')
+
+        # first assert that the lxd storage requires 'storage' api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            an_storage_pool.volumes.get('custom', 'cu1')
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
+
+        # now do the proper get
+        volume = an_storage_pool.volumes.get('custom', 'cu1')
+        self.assertEqual(volume.type, 'custom')
+        self.assertEqual(volume.name, 'cu1')
+        config = {
+            "block.filesystem": "ext4",
+            "block.mount_options": "discard",
+            "size": "10737418240"
+        }
+        self.assertEqual(volume.config, config)
+
+    def test_create(self):
+        an_storage_pool = models.StoragePool(self.client, name='lxd')
+        config = {
+            "config": {},
+            "pool": "lxd",
+            "name": "cu1",
+            "type": "custom"
+        }
+
+        # first assert that the lxd storage requires 'storage' api_extension
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
+            an_storage_pool.volumes.create(self.client, config)
+        # now make sure that it's available without mocking it out.
+        add_api_extension_helper(self, ['storage'])
+
+        a_volume = an_storage_pool.volumes.create(self.client, config)
+
+        self.assertEqual(config['name'], a_volume.name)
+
+    def test_rename(self):
+        add_api_extension_helper(self, ['storage'])
+        a_storage_pool = models.StoragePool(self.client, name='lxd')
+        a_volume = a_storage_pool.volumes.get('custom', 'cu1')
+
+        _input = {
+            "name": "vol1",
+            "pool": "pool3",
+            "migration": True
+        }
+        result = a_volume.rename(_input)
+        self.assertEqual(result['control'], 'secret1')
+        self.assertEqual(result['fs'], 'secret2')
+
+    def test_put(self):
+        add_api_extension_helper(self, ['storage'])
+        a_storage_pool = models.StoragePool(self.client, name='lxd')
+        a_volume = a_storage_pool.volumes.get('custom', 'cu1')
+        put_object = {
+            'config': {'size': 1}
+        }
+        thing = a_volume.put(put_object)
+
+    def test_patch(self):
+        add_api_extension_helper(self, ['storage'])
+        a_storage_pool = models.StoragePool(self.client, name='lxd')
+        a_volume = a_storage_pool.volumes.get('custom', 'cu1')
+        patch_object = {
+            'config': {'size': 1}
+        }
+        a_volume.patch(patch_object)
+
+    def test_save(self):
+        add_api_extension_helper(self, ['storage'])
+        a_storage_pool = models.StoragePool(self.client, name='lxd')
+        a_volume = a_storage_pool.volumes.get('custom', 'cu1')
+        a_volume.config = {'size': 2}
+        a_volume.save()
+
+    def test_delete(self):
+        add_api_extension_helper(self, ['storage'])
+        a_storage_pool = models.StoragePool(self.client, name='lxd')
+        a_volume = a_storage_pool.volumes.get('custom', 'cu1')
+        a_volume.delete()
+
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
index f0d48db..46dd6f9 100644
--- a/pylxd/tests/test_client.py
+++ b/pylxd/tests/test_client.py
@@ -239,6 +239,22 @@ def test_events_https(self):
 
         WebsocketClient.assert_called_once_with('wss://lxd.local')
 
+    def test_has_api_extension(self):
+        a_client = client.Client()
+        a_client.host_info = {'api_extensions': ["one", "two"]}
+        self.assertFalse(a_client.has_api_extension('three'))
+        self.assertTrue(a_client.has_api_extension('one'))
+        self.assertTrue(a_client.has_api_extension('two'))
+
+    def test_assert_has_api_extension(self):
+        a_client = client.Client()
+        a_client.host_info = {'api_extensions': ["one", "two"]}
+        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable) as c:
+            self.assertFalse(a_client.assert_has_api_extension('three'))
+        self.assertIn('three', str(c.exception))
+        a_client.assert_has_api_extension('one')
+        a_client.assert_has_api_extension('two')
+
 
 class TestAPINode(unittest.TestCase):
     """Tests for pylxd.client._APINode."""
@@ -355,6 +371,19 @@ def test_put(self, Session):
         node.put()
         session.put.assert_called_once_with('http://test.com', timeout=None)
 
+    @mock.patch('pylxd.client.requests.Session')
+    def test_patch(self, Session):
+        """Perform a session patch."""
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {'type': 'sync'},
+        })
+        session = mock.Mock(**{'patch.return_value': response})
+        Session.return_value = session
+        node = client._APINode('http://test.com')
+        node.patch()
+        session.patch.assert_called_once_with('http://test.com', timeout=None)
+
     @mock.patch('pylxd.client.requests.Session')
     def test_delete(self, Session):
         """Perform a session delete."""


More information about the lxc-devel mailing list