[lxc-devel] [pylxd/master] Add proper support for remote hosts

rockstar on Github lxc-bot at linuxcontainers.org
Sat May 28 04:53:49 UTC 2016


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 941 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20160528/f8c8418e/attachment.bin>
-------------- next part --------------
From 4f82309f41abbf452d62e16648f6059b1abb6fd7 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Thu, 26 May 2016 15:41:20 -0600
Subject: [PATCH 1/3] Fix https connection

Insecure connections are now supported.
---
 pylxd/client.py            | 27 +++++++++++++++------------
 pylxd/tests/test_client.py | 38 +++++++++++++++++++++++++-------------
 2 files changed, 40 insertions(+), 25 deletions(-)

diff --git a/pylxd/client.py b/pylxd/client.py
index 165e6ca..5248e5e 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -35,21 +35,24 @@ class _APINode(object):
     """An api node object.
     """
 
-    def __init__(self, api_endpoint):
+    def __init__(self, api_endpoint, verify=True):
         self._api_endpoint = api_endpoint
 
+        if self._api_endpoint.startswith('http+unix://'):
+            self.session = requests_unixsocket.Session()
+        else:
+            self.session = requests.Session()
+            self.session.verify = verify
+
     def __getattr__(self, name):
-        return self.__class__('{}/{}'.format(self._api_endpoint, name))
+        return self.__class__(
+            '{}/{}'.format(self._api_endpoint, name),
+            verify=self.session.verify)
 
     def __getitem__(self, item):
-        return self.__class__('{}/{}'.format(self._api_endpoint, item))
-
-    @property
-    def session(self):
-        if self._api_endpoint.startswith('http+unix://'):
-            return requests_unixsocket.Session()
-        else:
-            return requests
+        return self.__class__(
+            '{}/{}'.format(self._api_endpoint, item),
+            verify=self.session.verify)
 
     def get(self, *args, **kwargs):
         """Perform an HTTP GET."""
@@ -190,9 +193,9 @@ def __init__(self, client):
             self.all = functools.partial(Profile.all, client)
             self.create = functools.partial(Profile.create, client)
 
-    def __init__(self, endpoint=None, version='1.0'):
+    def __init__(self, endpoint=None, version='1.0', verify=True):
         if endpoint is not None:
-            self.api = _APINode(endpoint)
+            self.api = _APINode(endpoint, verify)
         else:
             if 'LXD_DIR' in os.environ:
                 path = os.path.join(
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
index 512ff49..d1c9c1c 100644
--- a/pylxd/tests/test_client.py
+++ b/pylxd/tests/test_client.py
@@ -89,7 +89,7 @@ def test_session_http(self):
         """HTTP nodes return the default requests session."""
         node = client._APINode('http://test.com')
 
-        self.assertEqual(requests, node.session)
+        self.assertIsInstance(node.session, requests.Session)
 
     def test_session_unix_socket(self):
         """HTTP nodes return a requests_unixsocket session."""
@@ -97,38 +97,50 @@ def test_session_unix_socket(self):
 
         self.assertIsInstance(node.session, requests_unixsocket.Session)
 
-    @mock.patch('pylxd.client.requests.get')
-    def test_get(self, get):
+    @mock.patch('pylxd.client.requests.Session')
+    def test_get(self, Session):
         """Perform a session get."""
+        session = mock.Mock()
+        Session.return_value = session
+
         node = client._APINode('http://test.com')
 
         node.get()
 
-        get.assert_called_once_with('http://test.com')
+        session.get.assert_called_once_with('http://test.com')
 
-    @mock.patch('pylxd.client.requests.post')
-    def test_post(self, post):
+    @mock.patch('pylxd.client.requests.Session')
+    def test_post(self, Session):
         """Perform a session post."""
+        session = mock.Mock()
+        Session.return_value = session
+
         node = client._APINode('http://test.com')
 
         node.post()
 
-        post.assert_called_once_with('http://test.com')
+        session.post.assert_called_once_with('http://test.com')
 
-    @mock.patch('pylxd.client.requests.put')
-    def test_put(self, put):
+    @mock.patch('pylxd.client.requests.Session')
+    def test_put(self, Session):
         """Perform a session put."""
+        session = mock.Mock()
+        Session.return_value = session
+
         node = client._APINode('http://test.com')
 
         node.put()
 
-        put.assert_called_once_with('http://test.com')
+        session.put.assert_called_once_with('http://test.com')
 
-    @mock.patch('pylxd.client.requests.delete')
-    def test_delete(self, delete):
+    @mock.patch('pylxd.client.requests.Session')
+    def test_delete(self, Session):
         """Perform a session delete."""
+        session = mock.Mock()
+        Session.return_value = session
+
         node = client._APINode('http://test.com')
 
         node.delete()
 
-        delete.assert_called_once_with('http://test.com')
+        session.delete.assert_called_once_with('http://test.com')

From 12484453b4c66fddbfa663d9345d4654d851e280 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Thu, 26 May 2016 15:46:09 -0600
Subject: [PATCH 2/3] Delete redundant tests

---
 pylxd/deprecated/tests/test_client.py | 90 -----------------------------------
 1 file changed, 90 deletions(-)
 delete mode 100644 pylxd/deprecated/tests/test_client.py

diff --git a/pylxd/deprecated/tests/test_client.py b/pylxd/deprecated/tests/test_client.py
deleted file mode 100644
index 821c59d..0000000
--- a/pylxd/deprecated/tests/test_client.py
+++ /dev/null
@@ -1,90 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    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 pylxd.client."""
-import unittest
-
-import mock
-
-from pylxd import client
-
-
-class Test_APINode(unittest.TestCase):
-    """Tests for pylxd.client._APINode."""
-    ROOT = 'http://lxd/api'
-
-    def test_getattr(self):
-        """`__getattr__` returns a nested node."""
-        lxd = client._APINode(self.ROOT)
-
-        self.assertEqual('{}/fake'.format(self.ROOT), lxd.fake._api_endpoint)
-
-    def test_getattr_nested(self):
-        """Nested objects return a more detailed path."""
-        lxd = client._APINode(self.ROOT)  # NOQA
-
-        self.assertEqual(
-            '{}/fake/path'.format(self.ROOT),
-            lxd.fake.path._api_endpoint)
-
-    def test_getitem(self):
-        """`__getitem__` enables dynamic url parts."""
-        lxd = client._APINode(self.ROOT)  # NOQA
-
-        self.assertEqual(
-            '{}/fake/path'.format(self.ROOT),
-            lxd.fake['path']._api_endpoint)
-
-    def test_getitem_integer(self):
-        """`__getitem__` with an integer allows dynamic integer url parts."""
-        lxd = client._APINode(self.ROOT)  # NOQA
-
-        self.assertEqual(
-            '{}/fake/0'.format(self.ROOT),
-            lxd.fake[0]._api_endpoint)
-
-    @mock.patch('pylxd.client.requests.get')
-    def test_get(self, _get):
-        """`get` will make a request to the smart url."""
-        lxd = client._APINode(self.ROOT)
-
-        lxd.fake.get()
-
-        _get.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
-    @mock.patch('pylxd.client.requests.post')
-    def test_post(self, _post):
-        """`post` will POST to the smart url."""
-        lxd = client._APINode(self.ROOT)
-
-        lxd.fake.post()
-
-        _post.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
-    @mock.patch('pylxd.client.requests.put')
-    def test_put(self, _put):
-        """`put` will PUT to the smart url."""
-        lxd = client._APINode(self.ROOT)
-
-        lxd.fake.put()
-
-        _put.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
-    @mock.patch('pylxd.client.requests.delete')
-    def test_delete(self, _delete):
-        """`delete` will DELETE to the smart url."""
-        lxd = client._APINode(self.ROOT)
-
-        lxd.fake.delete()
-
-        _delete.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))

From 908c06848e99e3f6ac66aaea1956a6ff49ce1db0 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Fri, 27 May 2016 22:42:15 -0600
Subject: [PATCH 3/3] Add certificate handling for LXD over https

This includes documentation and tests.
---
 doc/source/usage.rst       | 21 ++++++++++++++++-----
 pylxd/client.py            | 14 +++++++++-----
 pylxd/exceptions.py        |  5 +++++
 pylxd/tests/mock_lxd.py    |  2 +-
 pylxd/tests/test_client.py | 15 ++++++++++++---
 5 files changed, 43 insertions(+), 14 deletions(-)

diff --git a/doc/source/usage.rst b/doc/source/usage.rst
index cd66500..f12d1e8 100644
--- a/doc/source/usage.rst
+++ b/doc/source/usage.rst
@@ -10,9 +10,22 @@ localhost:
 
 .. code-block:: python
 
-    >>> from pylxd.client import Client
+    >>> from pylxd import Client
     >>> client = Client()
 
+If your LXD instance is listening on HTTPS, you can pass a two part tuple
+of (cert, key) as the `cert` argument.
+
+.. code-block:: python
+
+    >>> from pylxd import Client
+    >>> client = Client(
+    ...     endpoint='http://10.0.0.1:8443',
+    ...     cert=('/path/to/client.crt', '/path/to/client.key'))
+
+Note: in the case where the certificate is self signed (LXD default),
+you may need to pass `verify=False`.
+
 This :class:`~client.Client` object exposes managers for:
 
 - :class:`~container.Container`,
@@ -20,8 +33,6 @@ This :class:`~client.Client` object exposes managers for:
 - :class:`~operation.Operation`,
 - :class:`~image.Image`,
 
-Also, it exposes the HTTP API with the `api <api.html#Client.api>`_ attribute,
-allowing lower-level operations.
 
 Containers
 ==========
@@ -34,7 +45,7 @@ attribute, the partial of :meth:`Container.create
 
 .. code-block:: python
 
-    >>> container = client.container.create(dict(name='testcont'))
+    >>> container = client.containers.create(dict(name='testcont'))
     [<container.Container at 0x7f95d8af72b0>,]
 
 Example getting a list of :class:`~container.Container` with
@@ -42,7 +53,7 @@ Example getting a list of :class:`~container.Container` with
 
 .. code-block:: python
 
-    >>> client.container.all()
+    >>> client.containers.all()
     [<container.Container at 0x7f95d8af72b0>,]
 
 Examples
diff --git a/pylxd/client.py b/pylxd/client.py
index 5248e5e..bd097a2 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -35,24 +35,25 @@ class _APINode(object):
     """An api node object.
     """
 
-    def __init__(self, api_endpoint, verify=True):
+    def __init__(self, api_endpoint, cert=None, verify=True):
         self._api_endpoint = api_endpoint
 
         if self._api_endpoint.startswith('http+unix://'):
             self.session = requests_unixsocket.Session()
         else:
             self.session = requests.Session()
+            self.session.cert = cert
             self.session.verify = verify
 
     def __getattr__(self, name):
         return self.__class__(
             '{}/{}'.format(self._api_endpoint, name),
-            verify=self.session.verify)
+            cert=self.session.cert, verify=self.session.verify)
 
     def __getitem__(self, item):
         return self.__class__(
             '{}/{}'.format(self._api_endpoint, item),
-            verify=self.session.verify)
+            cert=self.session.cert, verify=self.session.verify)
 
     def get(self, *args, **kwargs):
         """Perform an HTTP GET."""
@@ -193,9 +194,9 @@ def __init__(self, client):
             self.all = functools.partial(Profile.all, client)
             self.create = functools.partial(Profile.create, client)
 
-    def __init__(self, endpoint=None, version='1.0', verify=True):
+    def __init__(self, endpoint=None, version='1.0', cert=None, verify=True):
         if endpoint is not None:
-            self.api = _APINode(endpoint, verify)
+            self.api = _APINode(endpoint, cert=cert, verify=verify)
         else:
             if 'LXD_DIR' in os.environ:
                 path = os.path.join(
@@ -211,6 +212,9 @@ def __init__(self, endpoint=None, version='1.0', verify=True):
             response = self.api.get()
             if response.status_code != 200:
                 raise exceptions.ClientConnectionFailed()
+            auth = response.json()['metadata']['auth']
+            if auth != "trusted":
+                raise exceptions.ClientAuthenticationFailed()
         except (requests.exceptions.ConnectionError,
                 requests.exceptions.InvalidURL):
             raise exceptions.ClientConnectionFailed()
diff --git a/pylxd/exceptions.py b/pylxd/exceptions.py
index 369458d..e30ba39 100644
--- a/pylxd/exceptions.py
+++ b/pylxd/exceptions.py
@@ -2,6 +2,11 @@ class ClientConnectionFailed(Exception):
     """An exception raised when the Client connection fails."""
 
 
+class ClientAuthenticationFailed(Exception):
+    """The LXD client's certificates are not trusted."""
+    message = "LXD client certificates are not trusted."""
+
+
 class _LXDAPIException(Exception):
     """A LXD API Exception.
 
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index d07622e..5b249ac 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -36,7 +36,7 @@ def profile_GET(request, context):
 RULES = [
     # General service endpoints
     {
-        'text': '',
+        'text': json.dumps({'metadata': {'auth': 'trusted'}}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0$',
     },
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
index d1c9c1c..00f12ac 100644
--- a/pylxd/tests/test_client.py
+++ b/pylxd/tests/test_client.py
@@ -5,7 +5,7 @@
 import requests
 import requests_unixsocket
 
-from pylxd import client
+from pylxd import client, exceptions
 
 
 class TestClient(unittest.TestCase):
@@ -16,6 +16,7 @@ def setUp(self):
         self.get = self.patcher.start()
 
         response = mock.MagicMock(status_code=200)
+        response.json.return_value = {'metadata': {'auth': 'trusted'}}
         self.get.return_value = response
 
     def tearDown(self):
@@ -52,7 +53,7 @@ def test_connection_404(self):
         response = mock.MagicMock(status_code=404)
         self.get.return_value = response
 
-        self.assertRaises(Exception, client.Client)
+        self.assertRaises(exceptions.ClientConnectionFailed, client.Client)
 
     def test_connection_failed(self):
         """If the connection fails, an exception is raised."""
@@ -61,7 +62,15 @@ def raise_exception():
         self.get.side_effect = raise_exception
         self.get.return_value = None
 
-        self.assertRaises(Exception, client.Client)
+        self.assertRaises(exceptions.ClientConnectionFailed, client.Client)
+
+    def test_authentication_failed(self):
+        """If the authentication fails, an exception is raised."""
+        response = mock.MagicMock(status_code=200)
+        response.json.return_value = {'metadata': {'auth': 'untrusted'}}
+        self.get.return_value = response
+
+        self.assertRaises(exceptions.ClientAuthenticationFailed, client.Client)
 
 
 class TestAPINode(unittest.TestCase):


More information about the lxc-devel mailing list