[lxc-devel] [pylxd/master] Add network operations

ppkt on Github lxc-bot at linuxcontainers.org
Wed Apr 25 11:05:03 UTC 2018


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 632 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20180425/f36ded41/attachment.bin>
-------------- next part --------------
From 89c989f4c45709f4c565a1d365232a44a4736e70 Mon Sep 17 00:00:00 2001
From: Karol Werner <karol at ppkt.eu>
Date: Tue, 24 Apr 2018 13:38:48 +0200
Subject: [PATCH 1/3] Update Network model, add unit tests

Signed-off-by: Karol Werner <karol at ppkt.eu>
---
 pylxd/models/network.py            |  44 +++++++---
 pylxd/tests/mock_lxd.py            |  69 ++++++++++++++--
 pylxd/tests/models/test_network.py | 161 ++++++++++++++++++++++++++++++++-----
 3 files changed, 235 insertions(+), 39 deletions(-)

diff --git a/pylxd/models/network.py b/pylxd/models/network.py
index c59fc01..57ff8dc 100644
--- a/pylxd/models/network.py
+++ b/pylxd/models/network.py
@@ -17,18 +17,27 @@
 class Network(model.Model):
     """A LXD network."""
     name = model.Attribute()
+    description = model.Attribute()
     type = model.Attribute()
-    used_by = model.Attribute()
     config = model.Attribute()
-    managed = model.Attribute()
+    managed = model.Attribute(readonly=True)
+    used_by = model.Attribute(readonly=True)
+
+    @classmethod
+    def exists(cls, client, name):
+        """Determine whether a network exists."""
+        try:
+            client.networks.get(name)
+            return True
+        except cls.NotFound:
+            return False
 
     @classmethod
     def get(cls, client, name):
         """Get a network by name."""
         response = client.api.networks[name].get()
 
-        network = cls(client, **response.json()['metadata'])
-        return network
+        return cls(client, **response.json()['metadata'])
 
     @classmethod
     def all(cls, client):
@@ -41,14 +50,25 @@ def all(cls, client):
             networks.append(cls(client, name=name))
         return networks
 
+    @classmethod
+    def create(cls, client, name, description=None, type_=None,
+               config=None):
+        """Create a network"""
+        network = {'name': name}
+        if description is not None:
+            network['description'] = description
+        if type_ is not None:
+            network['type'] = type_
+        if config is not None:
+            network['config'] = config
+        client.api.networks.post(json=network)
+        return cls.get(client, name)
+
+    def rename(self, new_name):
+        """Rename network."""
+        self.client.api.networks.post(json={'name': new_name})
+        return Network.get(self.client, new_name)
+
     @property
     def api(self):
         return self.client.api.networks[self.name]
-
-    def save(self, wait=False):
-        """Save is not available for networks."""
-        raise NotImplementedError('save is not implemented')
-
-    def delete(self):
-        """Delete is not available for networks."""
-        raise NotImplementedError('delete is not implemented')
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index d23004c..8e5cb0c 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -49,24 +49,37 @@ def image_DELETE(request, context):
         'operation': 'operation-abc'})
 
 
-def profiles_POST(request, context):
-    context.status_code = 200
+def networks_GET(request, _):
+    name = request.path.split('/')[-1]
     return json.dumps({
         'type': 'sync',
-        'metadata': {}})
+        'metadata': {
+            'config': {
+                'ipv4.address': '10.80.100.1/24',
+                'ipv4.nat': 'true',
+                'ipv6.address': 'none',
+                'ipv6.nat': 'false',
+            },
+            'name': name,
+            'description': 'Network description',
+            'type': 'bridge',
+            'managed': True,
+            'used_by': [],
+        },
+    })
 
 
-def profile_DELETE(request, context):
+def networks_POST(_, context):
     context.status_code = 200
     return json.dumps({
         'type': 'sync',
-        'operation': 'operation-abc'})
+        'metadata': {}})
 
 
-def snapshot_DELETE(request, context):
+def networks_DELETE(_, context):
     context.status_code = 202
     return json.dumps({
-        'type': 'async',
+        'type': 'sync',
         'operation': 'operation-abc'})
 
 
@@ -84,6 +97,27 @@ def profile_GET(request, context):
     })
 
 
+def profiles_POST(request, context):
+    context.status_code = 200
+    return json.dumps({
+        'type': 'sync',
+        'metadata': {}})
+
+
+def profile_DELETE(request, context):
+    context.status_code = 200
+    return json.dumps({
+        'type': 'sync',
+        'operation': 'operation-abc'})
+
+
+def snapshot_DELETE(request, context):
+    context.status_code = 202
+    return json.dumps({
+        'type': 'async',
+        'operation': 'operation-abc'})
+
+
 RULES = [
     # General service endpoints
     {
@@ -543,10 +577,16 @@ def profile_GET(request, context):
             'type': 'sync',
             'metadata': [
                 'http://pylxd.test/1.0/networks/lo',
+                'http://pylxd.test/1.0/networks/eth0',
             ]},
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/networks$',
     },
+    {
+        'text': networks_POST,
+        'method': 'POST',
+        'url': r'^http://pylxd.test/1.0/networks$',
+    },
     {
         'json': {
             'type': 'sync',
@@ -558,6 +598,21 @@ def profile_GET(request, context):
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/networks/lo$',
     },
+    {
+        'text': networks_GET,
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/networks/eth(0|1|2)$',
+    },
+    {
+        'text': json.dumps({'type': 'sync'}),
+        'method': 'PUT',
+        'url': r'^http://pylxd.test/1.0/networks/eth0$',
+    },
+    {
+        'text': networks_DELETE,
+        'method': 'DELETE',
+        'url': r'^http://pylxd.test/1.0/networks/eth0$',
+    },
 
     # Storage Pools
     {
diff --git a/pylxd/tests/models/test_network.py b/pylxd/tests/models/test_network.py
index c815438..6df755f 100644
--- a/pylxd/tests/models/test_network.py
+++ b/pylxd/tests/models/test_network.py
@@ -11,43 +11,164 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
-from pylxd import models
+import json
+
+from pylxd import models, exceptions
 from pylxd.tests import testing
 
 
 class TestNetwork(testing.PyLXDTestCase):
     """Tests for pylxd.models.Network."""
 
+    def test_get(self):
+        """A network is fetched."""
+        name = 'eth0'
+        an_network = models.Network.get(self.client, name)
+
+        self.assertEqual(name, an_network.name)
+
+    def test_get_not_found(self):
+        """LXDAPIException is raised on unknown network."""
+
+        def not_found(_, context):
+            context.status_code = 404
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 404})
+
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/networks/eth0$',
+        })
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            models.Network.get, self.client, 'eth0')
+
+    def test_get_error(self):
+        """LXDAPIException is raised on error."""
+
+        def error(_, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500,
+            })
+
+        self.add_rule({
+            'text': error,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/networks/eth0$',
+        })
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            models.Network.get, self.client, 'eth0')
+
+    def test_exists(self):
+        """True is returned if network exists."""
+        name = 'eth0'
+
+        self.assertTrue(models.Network.exists(self.client, name))
+
+    def test_not_exists(self):
+        """False is returned when network does not exist."""
+        def not_found(_, context):
+            context.status_code = 404
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 404})
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/networks/eth0$',
+        })
+
+        name = 'eth0'
+
+        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(1, len(networks))
+        self.assertEqual(2, len(networks))
 
-    def test_get(self):
-        """Return a container."""
-        name = 'lo'
+    def test_create(self):
+        """A new network is created."""
+        network = models.Network.create(
+            self.client, name='eth1', config={}, type_='bridge',
+            description='Network description')
 
-        an_network = models.Network.get(self.client, name)
+        self.assertIsInstance(network, models.Network)
+        self.assertEqual('eth1', network.name)
+        self.assertEqual('Network description', network.description)
+        self.assertEqual('bridge', network.type)
+        self.assertTrue(network.managed)
 
-        self.assertEqual(name, an_network.name)
+    def test_rename(self):
+        """A network is renamed."""
+        network = models.Network.get(self.client, 'eth0')
+
+        renamed_network = network.rename('eth2')
 
-    def test_partial(self):
+        self.assertEqual('eth2', renamed_network.name)
+
+    def test_update(self):
+        """A network is updated."""
+        network = models.Network.get(self.client, 'eth0')
+        network.config = {}
+        network.save()
+        self.assertEqual({}, network.config)
+
+    def test_fetch(self):
         """A partial network is synced."""
-        an_network = models.Network(self.client, name='lo')
+        network = self.client.networks.all()[1]
 
-        self.assertEqual('loopback', an_network.type)
+        network.sync()
 
-    def test_delete(self):
-        """delete is not implemented in networks."""
-        an_network = models.Network(self.client, name='lo')
+        self.assertEqual('Network description', network.description)
+
+    def test_fetch_not_found(self):
+        """LXDAPIException is raised on bogus network fetch."""
+        def not_found(_, context):
+            context.status_code = 404
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 404})
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/networks/eth0$',
+        })
+        network = models.Network(self.client, name='eth0')
 
-        with self.assertRaises(NotImplementedError):
-            an_network.delete()
+        self.assertRaises(exceptions.LXDAPIException, network.sync)
 
-    def test_save(self):
-        """save is not implemented in networks."""
-        an_network = models.Network(self.client, name='lo')
+    def test_fetch_error(self):
+        """LXDAPIException is raised on fetch error."""
+        def error(_, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500})
+        self.add_rule({
+            'text': error,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/networks/eth0$',
+        })
+        network = models.Network(self.client, name='eth0')
+
+        self.assertRaises(exceptions.LXDAPIException, network.sync)
+
+    def test_delete(self):
+        """A network is deleted."""
+        network = models.Network(self.client, name='eth0')
 
-        with self.assertRaises(NotImplementedError):
-            an_network.save()
+        network.delete()

From 3166e29fc1a89025ab504c1238ffb3528db97846 Mon Sep 17 00:00:00 2001
From: Karol Werner <karol at ppkt.eu>
Date: Wed, 25 Apr 2018 12:06:21 +0200
Subject: [PATCH 2/3] Add integration tests for Network

Signed-off-by: Karol Werner <karol at ppkt.eu>
---
 integration/test_networks.py | 111 +++++++++++++++++++++++++++++++++++++++++++
 integration/testing.py       |  19 +++++++-
 2 files changed, 129 insertions(+), 1 deletion(-)
 create mode 100644 integration/test_networks.py

diff --git a/integration/test_networks.py b/integration/test_networks.py
new file mode 100644
index 0000000..8380bfa
--- /dev/null
+++ b/integration/test_networks.py
@@ -0,0 +1,111 @@
+# 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.
+from integration.testing import IntegrationTestCase
+from pylxd import exceptions
+
+
+class TestNetworks(IntegrationTestCase):
+    """Tests for `Client.networks.`"""
+
+    def test_get(self):
+        """A network is fetched by its name."""
+        name = self.create_network()
+
+        self.addCleanup(self.delete_network, name)
+        network = self.client.networks.get(name)
+
+        self.assertEqual(name, network.name)
+
+    def test_all(self):
+        """All networks are fetched."""
+        name = self.create_network()
+
+        self.addCleanup(self.delete_network, name)
+
+        networks = self.client.networks.all()
+
+        self.assertIn(name, [network.name for network in networks])
+
+    def test_create_default_arguments(self):
+        """A network is created with default arguments"""
+        name = 'eth10'
+        network = self.client.networks.create(name=name)
+        self.addCleanup(self.delete_network, name)
+
+        self.assertEqual(name, network.name)
+        self.assertTrue(network.managed)
+        self.assertEqual('bridge', network.type)
+        self.assertEqual('', network.description)
+
+    def test_create_with_parameters(self):
+        """A network is created with provided arguments"""
+        kwargs = {
+            'name': 'eth10',
+            'config': {
+                'ipv4.address': '10.10.10.1/24',
+                'ipv4.nat': 'true',
+                'ipv6.address': 'none',
+                'ipv6.nat': 'false',
+            },
+            'type_': 'bridge',
+            'description': 'network description',
+        }
+
+        network = self.client.networks.create(**kwargs)
+        self.addCleanup(self.delete_network, kwargs['name'])
+
+        self.assertEqual(kwargs['name'], network.name)
+        self.assertEqual(kwargs['config'], network.config)
+        self.assertEqual(kwargs['type_'], network.type)
+        self.assertTrue(network.managed)
+        self.assertEqual(kwargs['description'], network.description)
+
+
+class TestNetwork(IntegrationTestCase):
+    """Tests for `Network`."""
+
+    def setUp(self):
+        super(TestNetwork, self).setUp()
+        name = self.create_network()
+        self.network = self.client.networks.get(name)
+
+    def tearDown(self):
+        super(TestNetwork, self).tearDown()
+        self.delete_network(self.network.name)
+
+    def test_save(self):
+        """A network is updated"""
+        self.network.config['ipv4.address'] = '11.11.11.1/24'
+        self.network.save()
+
+        network = self.client.networks.get(self.network.name)
+        self.assertEqual('11.11.11.1/24', network.config['ipv4.address'])
+
+    def test_rename(self):
+        """A network is renamed"""
+        name = 'eth20'
+        self.addCleanup(self.delete_network, name)
+
+        self.network.rename(name)
+        network = self.client.networks.get(name)
+
+        self.assertEqual(name, network.name)
+
+    def test_delete(self):
+        """A network is deleted"""
+        self.network.delete()
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            self.client.networks.get, self.network.name)
diff --git a/integration/testing.py b/integration/testing.py
index a6ea93b..33d145e 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -11,12 +11,14 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+import random
+import string
 import unittest
 import uuid
 
+from integration.busybox import create_busybox_image
 from pylxd import exceptions
 from pylxd.client import Client
-from integration.busybox import create_busybox_image
 
 
 class IntegrationTestCase(unittest.TestCase):
@@ -129,6 +131,21 @@ def delete_profile(self, name):
                 return
             raise
 
+    def create_network(self):
+        # get interface name in format xxx0
+        name = ''.join(random.sample(string.ascii_lowercase, 3)) + '0'
+        self.lxd.networks.post(json={
+            'name': name,
+            'config': {},
+        })
+        return name
+
+    def delete_network(self, name):
+        try:
+            self.lxd.networks[name].delete()
+        except exceptions.NotFound:
+            pass
+
     def assertCommon(self, response):
         """Assert common LXD responses.
 

From d813170cc0243e2842afad99e29cd3c84deaf307 Mon Sep 17 00:00:00 2001
From: Karol Werner <karol at ppkt.eu>
Date: Wed, 25 Apr 2018 12:51:08 +0200
Subject: [PATCH 3/3] Update Networks documentation

Signed-off-by: Karol Werner <karol at ppkt.eu>
---
 doc/source/networks.rst | 52 ++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 45 insertions(+), 7 deletions(-)

diff --git a/doc/source/networks.rst b/doc/source/networks.rst
index 385cb32..fa8bee8 100644
--- a/doc/source/networks.rst
+++ b/doc/source/networks.rst
@@ -1,8 +1,7 @@
 Networks
 ========
 
-`Network` objects show the current networks available to lxd. They are
-read-only via the REST API.
+`Network` objects show the current networks available to lxd.
 
 
 Manager methods
@@ -11,15 +10,54 @@ Manager methods
 Networks can be queried through the following client manager
 methods:
 
-  - `all()` - Retrieve all networks
+  - `all()` - Retrieve all networks.
+  - `exists()` - See if a profile with a name exists.  Returns `boolean`.
   - `get()` - Get a specific network, by its name.
+  - `create(name, description, type_, config)` - Create a new network.
+    The name of the network is required. `description`, `type_` and `config`
+    are optional and the scope of their contents is documented in the LXD
+    documentation.
 
 
 Network attributes
 ------------------
 
-  - `name` - The name of the network
-  - `type` - The type of the network
-  - `used_by` - A list of containers using this network
+  - `name` - The name of the network.
+  - `description` - The description of the network.
+  - `type` - The type of the network.
+  - `used_by` - A list of containers using this network.
   - `config` - The configuration associated with the network.
-  - `managed` - Boolean; whether LXD manages the network
+  - `managed` - `boolean`; whether LXD manages the network.
+
+
+Profile methods
+---------------
+
+  - `rename` - Rename the network.
+  - `save` - Save the network. This uses the PUT HTTP method and not the PATCH.
+  - `delete` - Deletes the network.
+
+Examples
+--------
+
+:class:`~network.Network` operations follow the same manager-style as other
+classes. Network are keyed on a unique name.
+
+.. code-block:: python
+
+    >>> network = client.networks.get('lxdbr0')
+    >>> network
+    <pylxd.models.network.Network object at 0x7f66ae4a2840>
+
+
+The network can then be modified and saved.
+
+    >>> network.config['ipv4.address'] = '10.253.10.1/24'
+    >>> network.save()
+
+
+To create a new network, use `create` with a name, and optional arguments:
+`description` and `type_` and `config`.
+
+    >>> network = client.networks.create(
+    ...     'lxdbr1', description='My new network', type_='bridge', config={})


More information about the lxc-devel mailing list