[lxc-devel] [pylxd/master] Add support for lxd projects

d0ugal on Github lxc-bot at linuxcontainers.org
Fri Dec 4 16:43:27 UTC 2020


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 360 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20201204/2990bdf8/attachment-0001.bin>
-------------- next part --------------
From 2687a67764f8c9504a801793f9699edfd0fae3b6 Mon Sep 17 00:00:00 2001
From: Dougal Matthews <dougal at dougalmatthews.com>
Date: Thu, 3 Dec 2020 10:40:05 +0000
Subject: [PATCH] Add support for lxd projects

Signed-off-by: Dougal Matthews <dougal at dougalmatthews.com>
---
 integration/test_projects.py       |  87 +++++++++++++++
 pylxd/client.py                    |   5 +
 pylxd/managers.py                  |   4 +
 pylxd/models/__init__.py           |   2 +
 pylxd/models/project.py            |  70 ++++++++++++
 pylxd/tests/models/test_project.py | 174 +++++++++++++++++++++++++++++
 6 files changed, 342 insertions(+)
 create mode 100644 integration/test_projects.py
 create mode 100644 pylxd/models/project.py
 create mode 100644 pylxd/tests/models/test_project.py

diff --git a/integration/test_projects.py b/integration/test_projects.py
new file mode 100644
index 00000000..e8db0f2b
--- /dev/null
+++ b/integration/test_projects.py
@@ -0,0 +1,87 @@
+# 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 pylxd import exceptions
+
+from integration.testing import IntegrationTestCase
+
+
+class TestProjects(IntegrationTestCase):
+    """Tests for `Client.projects.`"""
+
+    def test_get(self):
+        """A project is fetched by name."""
+        name = self.create_project()
+        self.addCleanup(self.delete_project, name)
+
+        project = self.client.projects.get(name)
+
+        self.assertEqual(name, project.name)
+
+    def test_all(self):
+        """All projects are fetched."""
+        name = self.create_project()
+        self.addCleanup(self.delete_project, name)
+
+        projects = self.client.projects.all()
+
+        self.assertIn(name, [project.name for project in projects])
+
+    def test_create(self):
+        """A project is created."""
+        name = "an-project"
+        config = {"limits.memory": "1GB"}
+        project = self.client.projects.create(name, config)
+        self.addCleanup(self.delete_project, name)
+
+        self.assertEqual(name, project.name)
+        self.assertEqual(config, project.config)
+
+
+class TestProject(IntegrationTestCase):
+    """Tests for `Project`."""
+
+    def setUp(self):
+        super(TestProject, self).setUp()
+        name = self.create_project()
+        self.project = self.client.projects.get(name)
+
+    def tearDown(self):
+        super(TestProject, self).tearDown()
+        self.delete_project(self.project.name)
+
+    def test_save(self):
+        """A project is updated."""
+        self.project.config["limits.memory"] = "16GB"
+        self.project.save()
+
+        project = self.client.projects.get(self.project.name)
+        self.assertEqual("16GB", project.config["limits.memory"])
+
+    def test_rename(self):
+        """A project is renamed."""
+        name = "a-other-project"
+        self.addCleanup(self.delete_project, name)
+
+        self.project.rename(name)
+        project = self.client.projects.get(name)
+
+        self.assertEqual(name, project.name)
+
+    def test_delete(self):
+        """A project is deleted."""
+        self.project.delete()
+
+        self.assertRaises(
+            exceptions.LXDAPIException, self.client.projects.get, self.project.name
+        )
diff --git a/pylxd/client.py b/pylxd/client.py
index 78c848ee..ae3007f1 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -258,6 +258,10 @@ class Client(object):
 
         Instance of :class:`Client.Profiles <pylxd.client.Client.Profiles>`.
 
+    .. attribute::projects
+
+        Instance of :class:`Client.Project <pylxd.client.Client.Project >`.
+
     .. attribute:: api
 
         This attribute provides tree traversal syntax to LXD's REST API for
@@ -345,6 +349,7 @@ def __init__(
         self.networks = managers.NetworkManager(self)
         self.operations = managers.OperationManager(self)
         self.profiles = managers.ProfileManager(self)
+        self.projects = managers.ProjectManager(self)
         self.storage_pools = managers.StoragePoolManager(self)
         self._resource_cache = None
 
diff --git a/pylxd/managers.py b/pylxd/managers.py
index 477d70df..723a6822 100644
--- a/pylxd/managers.py
+++ b/pylxd/managers.py
@@ -57,6 +57,10 @@ class ProfileManager(BaseManager):
     manager_for = "pylxd.models.Profile"
 
 
+class ProjectManager(BaseManager):
+    manager_for = "pylxd.models.Project"
+
+
 class SnapshotManager(BaseManager):
     manager_for = "pylxd.models.Snapshot"
 
diff --git a/pylxd/models/__init__.py b/pylxd/models/__init__.py
index 4cb1d006..51c3954c 100644
--- a/pylxd/models/__init__.py
+++ b/pylxd/models/__init__.py
@@ -6,6 +6,7 @@
 from pylxd.models.network import Network
 from pylxd.models.operation import Operation
 from pylxd.models.profile import Profile
+from pylxd.models.project import Project
 from pylxd.models.storage_pool import StoragePool, StorageResources, StorageVolume
 from pylxd.models.virtual_machine import VirtualMachine
 
@@ -19,6 +20,7 @@
     "Network",
     "Operation",
     "Profile",
+    "Project",
     "Snapshot",
     "StoragePool",
     "StorageResources",
diff --git a/pylxd/models/project.py b/pylxd/models/project.py
new file mode 100644
index 00000000..caa40807
--- /dev/null
+++ b/pylxd/models/project.py
@@ -0,0 +1,70 @@
+# Copyright (c) 2020 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 pylxd.models import _model as model
+
+
+class Project(model.Model):
+    """A LXD project."""
+
+    name = model.Attribute(readonly=True)
+    config = model.Attribute()
+    description = model.Attribute()
+    used_by = model.Attribute(readonly=True)
+
+    @classmethod
+    def exists(cls, client, name):
+        """Determine whether a project exists."""
+        try:
+            client.projects.get(name)
+            return True
+        except cls.NotFound:
+            return False
+
+    @classmethod
+    def get(cls, client, name):
+        """Get a project."""
+        response = client.api.projects[name].get()
+        return cls(client, **response.json()["metadata"])
+
+    @classmethod
+    def all(cls, client):
+        """Get all projects."""
+        response = client.api.projects.get()
+
+        projects = []
+        for url in response.json()["metadata"]:
+            name = url.split("/")[-1]
+            projects.append(cls(client, name=name))
+        return projects
+
+    @classmethod
+    def create(cls, client, name, config=None, devices=None):
+        """Create a project."""
+        project = {"name": name}
+        if config is not None:
+            project["config"] = config
+        if devices is not None:
+            project["devices"] = devices
+        client.api.projects.post(json=project)
+        return cls.get(client, name)
+
+    @property
+    def api(self):
+        return self.client.api.projects[self.name]
+
+    def rename(self, new_name):
+        """Rename the project."""
+        self.api.post(json={"name": new_name})
+
+        return Project.get(self.client, new_name)
diff --git a/pylxd/tests/models/test_project.py b/pylxd/tests/models/test_project.py
new file mode 100644
index 00000000..cfcd5b9a
--- /dev/null
+++ b/pylxd/tests/models/test_project.py
@@ -0,0 +1,174 @@
+import json
+
+from pylxd import exceptions, models
+from pylxd.tests import testing
+
+
+class TestProject(testing.PyLXDTestCase):
+    """Tests for pylxd.models.Project."""
+
+    def test_get(self):
+        """A project is fetched."""
+        name = "an-project"
+        an_project = models.Project.get(self.client, name)
+
+        self.assertEqual(name, an_project.name)
+
+    def test_get_not_found(self):
+        """LXDAPIException is raised on unknown projects."""
+
+        def not_found(request, 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/projects/an-project$",
+            }
+        )
+
+        self.assertRaises(
+            exceptions.LXDAPIException, models.Project.get, self.client, "an-project"
+        )
+
+    def test_get_error(self):
+        """LXDAPIException is raised on get error."""
+
+        def error(request, 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/projects/an-project$",
+            }
+        )
+
+        self.assertRaises(
+            exceptions.LXDAPIException, models.Project.get, self.client, "an-project"
+        )
+
+    def test_exists(self):
+        name = "an-project"
+
+        self.assertTrue(models.Project.exists(self.client, name))
+
+    def test_not_exists(self):
+        def not_found(request, 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/projects/an-project$",
+            }
+        )
+
+        name = "an-project"
+
+        self.assertFalse(models.Project.exists(self.client, name))
+
+    def test_all(self):
+        """A list of all projects is returned."""
+        projects = models.Project.all(self.client)
+
+        self.assertEqual(1, len(projects))
+
+    def test_create(self):
+        """A new project is created."""
+        an_project = models.Project.create(
+            self.client, name="an-new-project", config={}, devices={}
+        )
+
+        self.assertIsInstance(an_project, models.Project)
+        self.assertEqual("an-new-project", an_project.name)
+
+    def test_rename(self):
+        """A project is renamed."""
+        an_project = models.Project.get(self.client, "an-project")
+
+        an_renamed_project = an_project.rename("an-renamed-project")
+
+        self.assertEqual("an-renamed-project", an_renamed_project.name)
+
+    def test_update(self):
+        """A project is updated."""
+        # XXX: rockstar (03 Jun 2016) - This just executes
+        # a code path. There should be an assertion here, but
+        # it's not clear how to assert that, just yet.
+        an_project = models.Project.get(self.client, "an-project")
+
+        an_project.save()
+
+        self.assertEqual({}, an_project.config)
+
+    def test_fetch(self):
+        """A partially fetched project is made complete."""
+        an_project = self.client.projects.all()[0]
+
+        an_project.sync()
+
+        self.assertEqual("An description", an_project.description)
+
+    def test_fetch_notfound(self):
+        """LXDAPIException is raised on bogus project fetches."""
+
+        def not_found(request, 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/projects/an-project$",
+            }
+        )
+
+        an_project = models.Project(self.client, name="an-project")
+
+        self.assertRaises(exceptions.LXDAPIException, an_project.sync)
+
+    def test_fetch_error(self):
+        """LXDAPIException is raised on fetch error."""
+
+        def error(request, 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/projects/an-project$",
+            }
+        )
+
+        an_project = models.Project(self.client, name="an-project")
+
+        self.assertRaises(exceptions.LXDAPIException, an_project.sync)
+
+    def test_delete(self):
+        """A project is deleted."""
+        # XXX: rockstar (03 Jun 2016) - This just executes
+        # a code path. There should be an assertion here, but
+        # it's not clear how to assert that, just yet.
+        an_project = self.client.projects.all()[0]
+
+        an_project.delete()


More information about the lxc-devel mailing list