[lxc-devel] [pylxd/master] 2 0 api
rockstar on Github
lxc-bot at linuxcontainers.org
Tue Feb 16 01:34:41 UTC 2016
A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 1132 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20160216/5ebba572/attachment.bin>
-------------- next part --------------
From caea534282c3309542fb517e0af77a13912e62eb Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Thu, 14 Jan 2016 05:59:10 -0700
Subject: [PATCH 01/20] Add integration tests for containers.
---
integration/test_1_0.py | 40 +++++++
integration/test_1_0_containers.py | 232 +++++++++++++++++++++++++++++++++++++
integration/test_base.py | 126 --------------------
integration/test_root.py | 25 ++++
integration/testing.py | 57 +++++++++
pylxd/api.py | 8 ++
pylxd/tests/test_api.py | 18 +++
setup.cfg | 3 +
8 files changed, 383 insertions(+), 126 deletions(-)
create mode 100644 integration/test_1_0.py
create mode 100644 integration/test_1_0_containers.py
delete mode 100644 integration/test_base.py
create mode 100644 integration/test_root.py
create mode 100644 integration/testing.py
diff --git a/integration/test_1_0.py b/integration/test_1_0.py
new file mode 100644
index 0000000..1218893
--- /dev/null
+++ b/integration/test_1_0.py
@@ -0,0 +1,40 @@
+# 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
+
+
+class Test10(IntegrationTestCase):
+ """Tests for /1.0"""
+
+ def test_1_0(self):
+ """Return: Dict representing server state."""
+ result = self.lxd['1.0'].get()
+
+ self.assertCommon(result)
+ self.assertEqual(200, result.status_code)
+ self.assertEqual(
+ ['api_compat', 'auth', 'config', 'environment'],
+ sorted(result.json()['metadata'].keys()))
+ self.assertEqual(
+ ['addresses', 'architectures', 'driver', 'driver_version', 'kernel',
+ 'kernel_architecture', 'kernel_version', 'server', 'server_pid',
+ 'server_version', 'storage', 'storage_version'],
+ sorted(result.json()['metadata']['environment'].keys()))
+
+ def test_1_0_PUT(self):
+ """Return: standard return value or standard error."""
+ result = self.lxd['1.0'].put(json={'config': {'core.trust_password': 'test'}})
+
+ self.assertCommon(result)
+ self.assertEqual(200, result.status_code)
diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
new file mode 100644
index 0000000..c376870
--- /dev/null
+++ b/integration/test_1_0_containers.py
@@ -0,0 +1,232 @@
+# 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
+
+
+class Test10Containers(IntegrationTestCase):
+ """Tests for /1.0/containers"""
+
+ def test_1_0_containers(self):
+ """Return: list of URLs for containers this server publishes."""
+ result = self.lxd['1.0']['containers'].get()
+
+ self.assertCommon(result)
+ self.assertEqual(200, result.status_code)
+
+ def test_1_0_containers_POST(self):
+ """Return: background operation or standard error."""
+ name = self.create_container()
+ self.addCleanup(self.delete_container, name)
+
+ machine = {
+ 'name': name,
+ 'architecture': 2,
+ 'profiles': ['default'],
+ 'ephemeral': True,
+ 'config': {'limits.cpu': '2'},
+ 'source': {'type': 'image',
+ 'alias': 'busybox'},
+ }
+ result = self.lxd['1.0']['containers'].post(json=machine)
+
+ # self.assertCommon(result)
+ self.assertEqual(202, result.status_code)
+
+
+class ContainerTestCase(IntegrationTestCase):
+ """A Container-specific test case."""
+
+ def setUp(self):
+ super(ContainerTestCase, self).setUp()
+ self.name = self.create_container()
+ self.addCleanup(self.delete_container, self.name)
+
+
+class Test10Container(ContainerTestCase):
+ """Tests for /1.0/containers/<name>"""
+
+ def test10container(self):
+ """Return: dict of the container configuration and current state."""
+ result = self.lxd['1.0']['containers'][self.name].get()
+
+ self.assertEqual(200, result.status_code)
+
+ def test10container_put(self):
+ """Return: background operation or standard error."""
+ result = self.lxd['1.0']['containers'][self.name].put(json={
+ 'config': {'limits.cpu': '1'}
+ })
+
+ self.assertEqual(202, result.status_code)
+
+ def test10container_post(self):
+ """Return: background operation or standard error."""
+ new_name = 'newname'
+ result = self.lxd['1.0']['containers'][self.name].post(json={
+ 'name': new_name,
+ })
+ self.addCleanup(self.delete_container, new_name)
+
+ self.assertEqual(202, result.status_code)
+
+ def test10container_delete(self):
+ """Return: background operation or standard error."""
+ result = self.lxd['1.0']['containers'][self.name].delete()
+
+ self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerState(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/state."""
+
+ def test10containerstate(self):
+ """Return: dict representing current state."""
+ result = self.lxd['1.0'].containers[self.name].state.get()
+
+ self.assertEqual(200, result.status_code)
+
+ def test10containerstate_PUT(self):
+ """Return: background operation or standard error."""
+ result = self.lxd['1.0'].containers[self.name].state.put(json={
+ 'action': 'start',
+ 'timeout': 30,
+ 'force': True
+ })
+
+ self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerFiles(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/files."""
+
+ def test10containerfiles(self):
+ """Return: dict representing current files."""
+ result = self.lxd['1.0'].containers[self.name].files.get(params={
+ 'path': '/bin/sh'
+ })
+
+ self.assertEqual(200, result.status_code)
+
+ def test10containerfiles_POST(self):
+ """Return: standard return value or standard error."""
+ result = self.lxd['1.0'].containers[self.name].files.get(params={
+ 'path': '/bin/sh'
+ }, data='abcdef')
+
+ self.assertEqual(200, result.status_code)
+
+
+class Test10ContainerSnapshots(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/snapshots."""
+
+ def test10containersnapshots(self):
+ """Return: list of URLs for snapshots for this container."""
+ result = self.lxd['1.0'].containers[self.name].snapshots.get()
+
+ self.assertEqual(200, result.status_code)
+
+ def test10containersnapshots_POST(self):
+ """Return: background operation or standard error."""
+ result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
+ 'name': 'test-snapshot',
+ 'stateful': False
+ })
+
+ self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerSnapshot(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/snapshot/<name>."""
+
+ def setUp(self):
+ super(Test10ContainerSnapshot, self).setUp()
+ result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
+ 'name': 'test-snapshot', 'stateful': False})
+ operation_uuid = result.json()['operation'].split('/')[-1]
+ result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+ def test10containersnapshot(self):
+ """Return: dict representing the snapshot."""
+ result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].get()
+
+ self.assertEqual(200, result.status_code)
+
+ def test10containersnapshot_POST(self):
+ """Return: dict representing the snapshot."""
+ result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].post(json={
+ 'name': 'test-snapshot.bak-lol'
+ })
+
+ self.assertEqual(202, result.status_code)
+
+ def test10containersnapshot_DELETE(self):
+ """Return: dict representing the snapshot."""
+ result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].delete()
+
+ self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerExec(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/exec."""
+
+ def setUp(self):
+ super(Test10ContainerExec, self).setUp()
+
+ result = self.lxd['1.0'].containers[self.name].state.put(json={
+ 'action': 'start', 'timeout': 30, 'force': True})
+ operation_uuid = result.json()['operation'].split('/')[-1]
+ self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+ def test10containerexec(self):
+ """Return: background operation + optional websocket information.
+
+ ...or standard error."""
+ result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
+ 'command': ['/bin/bash'],
+ 'wait-for-websocket': False,
+ 'interactive': True,
+ })
+
+ self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerLogs(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/logs."""
+
+ def test10containerlogs(self):
+ """Return: a list of the available log files."""
+ result = self.lxd['1.0'].containers[self.name].logs.get()
+
+ self.assertEqual(200, result.status_code)
+
+
+class Test10ContainerLog(ContainerTestCase):
+ """Tests for /1.0/containers/<name>/logs/<logfile>."""
+
+ def setUp(self):
+ super(Test10ContainerLog, self).setUp()
+ result = self.lxd['1.0'].containers[self.name].logs.get()
+ self.log_name = result.json()['metadata'][0]['name']
+
+ def test10containerlog(self):
+ """Return: the contents of the log file."""
+ result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
+
+ self.assertEqual(200, result.status_code)
+
+ def test10containerlog_DELETE(self):
+ """Return: the contents of the log file."""
+ result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
+
+ self.assertEqual(200, result.status_code)
diff --git a/integration/test_base.py b/integration/test_base.py
deleted file mode 100644
index 486c626..0000000
--- a/integration/test_base.py
+++ /dev/null
@@ -1,126 +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.
-
-import unittest
-
-from pylxd.api import LXD
-
-CERT = '''MIICATCCAWoCCQDejRDAWZlG0DANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJV
-UzETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
-cyBQdHkgTHRkMB4XDTE2MDExMzE0MTAyNFoXDTI2MDExMDE0MTAyNFowRTELMAkG
-A1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0
-IFdpZGdpdHMgUHR5IEx0ZDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv4bk
-u8i1q/5vl8VbSALC4Q2MHcyVgQ7I8RGnTZArbD5fGAhhvBXki2w2fo8eoaOV7hh0
-bAo+t7rn4RE39OQbdeyAdG62qkRug58eeUb0Qz2Zcg9CQ5vcbApElykHmMt2yXW3
-gxtPu0mqUQnUt2zs7/8okwtfc2SMZKpwrUrxDU8CAwEAATANBgkqhkiG9w0BAQsF
-AAOBgQA2c14J5FbxMFxTJuEjiIY1s4eJCW1XDC0SO2WRY3iz0fwKautLoOnZJOQk
-OZNzJlCVpZjpHeL847mz2ybtoFpUO45bWGg75W5gh4D2gmnG+FlCg2l3Tno1bMoS
-tTQu8yyFguWnWnCokzEovlKMR1YajPvndmzf7zRujcL2vKL5uA==
-'''
-
-
-class TestBaseAssumptions(unittest.TestCase):
- """lxd makes some common base assumptions about the rest api."""
-
- def setUp(self):
- self.lxd = LXD('http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket')
-
- def test_root(self):
- """GET to / is allowed for everyone (lists the API endpoints)."""
- expected = {
- 'type': 'sync',
- 'status': 'Success',
- 'status_code': 200,
- 'metadata': ['/1.0']
- }
-
- result = self.lxd.get()
-
- self.assertEqual(200, result.status_code)
- self.assertEqual(expected, result.json())
-
- def test_1_0(self):
- """GET to /1.0 is allowed for everyone (but result varies)."""
- expected = {
- "type": "sync",
- "status": "Success",
- "status_code": 200,
- "metadata": {
- "api_compat": 1,
- "auth": "trusted",
- "config": {
- "images.remote_cache_expiry": "10"
- },
- "environment": {
- "addresses": [],
- "architectures": [2, 1],
- "driver": "lxc",
- "driver_version": "1.1.5",
- "kernel": "Linux",
- "kernel_architecture": "x86_64",
- "kernel_version": "4.2.0-19-generic",
- "server": "lxd",
- "server_pid": 7453,
- "server_version": "0.20",
- "storage": "dir",
- "storage_version": ""
- }
- }
- }
-
- result = self.lxd['1.0'].get()
-
- self.assertEqual(200, result.status_code)
- self.assertEqual(expected, result.json())
-
- def test_1_0_images(self):
- """GET to /1.0/images/* is allowed for everyone.
-
- ...but only returns public images for unauthenticated users.
- """
- expected = {
- "type": "sync",
- "status": "Success",
- "status_code": 200,
- "metadata": []
- }
-
- result = self.lxd['1.0'].images.get()
- json = result.json()
- # metadata should exist, but its content varies.
- json['metadata'] = []
-
- self.assertEqual(200, result.status_code)
- self.assertEqual(expected, json)
-
- def test_1_0_certificates_POST(self):
- """POST to /1.0/certificates is allowed for everyone.
-
- ...with a client certificate.
- """
- expected = {
- "type": "sync",
- "status": "Success",
- "status_code": 200,
- "metadata": {}
- }
-
- data = {
- 'type': 'client',
- 'certificate': CERT,
- }
- result = self.lxd['1.0'].certificates.post(json=data)
-
- self.assertEqual(200, result.status_code)
- self.assertEqual(expected, result.json())
diff --git a/integration/test_root.py b/integration/test_root.py
new file mode 100644
index 0000000..aeae2ae
--- /dev/null
+++ b/integration/test_root.py
@@ -0,0 +1,25 @@
+# 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
+
+
+class TestRoot(IntegrationTestCase):
+ """Tests for /"""
+
+ def test_root(self):
+ """Return: list of supported API endpoint URLs."""
+ result = self.lxd.get()
+
+ self.assertCommon(result)
+ self.assertEqual(200, result.status_code)
diff --git a/integration/testing.py b/integration/testing.py
new file mode 100644
index 0000000..29a3bc8
--- /dev/null
+++ b/integration/testing.py
@@ -0,0 +1,57 @@
+# 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.
+import unittest
+import uuid
+
+from pylxd.api import LXD
+
+
+class IntegrationTestCase(unittest.TestCase):
+ """A base test case for pylxd integration tests."""
+
+ def setUp(self):
+ super(IntegrationTestCase, self).setUp()
+ self.lxd = LXD('http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket')
+
+ def create_container(self):
+ """Create a container in lxd."""
+ name = 'a' + str(uuid.uuid4())
+ machine = {
+ 'name': name,
+ 'architecture': 2,
+ 'profiles': ['default'],
+ 'ephemeral': True,
+ 'config': {'limits.cpu': '2'},
+ 'source': {'type': 'image',
+ 'alias': 'busybox'},
+ }
+ result = self.lxd['1.0']['containers'].post(json=machine)
+ operation_uuid = result.json()['operation'].split('/')[-1]
+ self.lxd['1.0'].operations[operation_uuid].wait.get()
+ return name
+
+ def delete_container(self, name):
+ """Delete a container in lxd."""
+ self.lxd['1.0']['containers'][name].delete()
+
+ def assertCommon(self, response):
+ """Assert common LXD responses.
+
+ LXD responses are relatively standard. This function makes assertions
+ to all those standards.
+ """
+ self.assertEqual(response.status_code, response.json()['status_code'])
+ self.assertEqual(
+ ['metadata', 'operation', 'status', 'status_code', 'type'],
+ sorted(response.json().keys()))
diff --git a/pylxd/api.py b/pylxd/api.py
index a08a202..f9a1370 100644
--- a/pylxd/api.py
+++ b/pylxd/api.py
@@ -62,6 +62,14 @@ def get(self, *args, **kwargs):
def post(self, *args, **kwargs):
"""Perform an HTTP POST."""
return self.session.post(self._api_endpoint, *args, **kwargs)
+
+ def put(self, *args, **kwargs):
+ """Perform an HTTP PUT."""
+ return self.session.put(self._api_endpoint, *args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ """Perform an HTTP delete."""
+ return self.session.delete(self._api_endpoint, *args, **kwargs)
LXD = _APINode
diff --git a/pylxd/tests/test_api.py b/pylxd/tests/test_api.py
index 8f84f68..60fb7f8 100644
--- a/pylxd/tests/test_api.py
+++ b/pylxd/tests/test_api.py
@@ -70,3 +70,21 @@ def test_post(self, _post):
lxd.fake.post()
_post.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+ @mock.patch('pylxd.api.requests.put')
+ def test_put(self, _put):
+ """`put` will PUT to the smart url."""
+ lxd = api._APINode(self.ROOT)
+
+ lxd.fake.put()
+
+ _put.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+ @mock.patch('pylxd.api.requests.delete')
+ def test_delete(self, _delete):
+ """`delete` will DELETE to the smart url."""
+ lxd = api._APINode(self.ROOT)
+
+ lxd.fake.delete()
+
+ _delete.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
diff --git a/setup.cfg b/setup.cfg
index d3ab14d..b2666d7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -46,3 +46,6 @@ input_file = pylxd/locale/pylxd.pot
keywords = _ gettext ngettext l_ lazy_gettext
mapping_file = babel.cfg
output_file = pylxd/locale/pylxd.pot
+
+[nosetests]
+nologcapture=1
From 3cafbeb17edbc48250c460f38b13e80be3d4139f Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 29 Jan 2016 11:55:22 -0700
Subject: [PATCH 02/20] Add more integration tests
---
integration/busybox.py | 155 +++++++++++++++++++++++++++++++++++++
integration/test_1_0_containers.py | 68 ++++++++--------
integration/test_1_0_events.py | 27 +++++++
integration/test_1_0_images.py | 127 ++++++++++++++++++++++++++++++
integration/testing.py | 43 ++++++++--
5 files changed, 384 insertions(+), 36 deletions(-)
create mode 100644 integration/busybox.py
create mode 100644 integration/test_1_0_events.py
create mode 100644 integration/test_1_0_images.py
diff --git a/integration/busybox.py b/integration/busybox.py
new file mode 100644
index 0000000..8e87b00
--- /dev/null
+++ b/integration/busybox.py
@@ -0,0 +1,155 @@
+# This code is stolen directly from lxd-images, for expediency's sake.
+import atexit
+import hashlib
+import io
+import json
+import os
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import uuid
+
+
+def find_on_path(command):
+ """Is command on the executable search path?"""
+
+ if 'PATH' not in os.environ:
+ return False
+ path = os.environ['PATH']
+ for element in path.split(os.pathsep):
+ if not element:
+ continue
+ filename = os.path.join(element, command)
+ if os.path.isfile(filename) and os.access(filename, os.X_OK):
+ return True
+ return False
+
+
+class Busybox(object):
+ workdir = None
+
+ def __init__(self):
+ # Create our workdir
+ self.workdir = tempfile.mkdtemp()
+
+ def cleanup(self):
+ if self.workdir:
+ shutil.rmtree(self.workdir)
+
+ def create_tarball(self, split=False):
+ xz = "pxz" if find_on_path("pxz") else "xz"
+
+ destination_tar = os.path.join(self.workdir, "busybox.tar")
+ target_tarball = tarfile.open(destination_tar, "w:")
+
+ if split:
+ destination_tar_rootfs = os.path.join(self.workdir,
+ "busybox.rootfs.tar")
+ target_tarball_rootfs = tarfile.open(destination_tar_rootfs, "w:")
+
+ metadata = {'architecture': os.uname()[4],
+ 'creation_date': int(os.stat("/bin/busybox").st_ctime),
+ 'properties': {
+ 'os': "Busybox",
+ 'architecture': os.uname()[4],
+ 'description': "Busybox %s" % os.uname()[4],
+ 'name': "busybox-%s" % os.uname()[4],
+ # Don't overwrite actual busybox images.
+ 'obfuscate': str(uuid.uuid4),
+ },
+ }
+
+ # Add busybox
+ with open("/bin/busybox", "rb") as fd:
+ busybox_file = tarfile.TarInfo()
+ busybox_file.size = os.stat("/bin/busybox").st_size
+ busybox_file.mode = 0o755
+ if split:
+ busybox_file.name = "bin/busybox"
+ target_tarball_rootfs.addfile(busybox_file, fd)
+ else:
+ busybox_file.name = "rootfs/bin/busybox"
+ target_tarball.addfile(busybox_file, fd)
+
+ # Add symlinks
+ busybox = subprocess.Popen(["/bin/busybox", "--list-full"],
+ stdout=subprocess.PIPE,
+ universal_newlines=True)
+ busybox.wait()
+
+ for path in busybox.stdout.read().split("\n"):
+ if not path.strip():
+ continue
+
+ symlink_file = tarfile.TarInfo()
+ symlink_file.type = tarfile.SYMTYPE
+ symlink_file.linkname = "/bin/busybox"
+ if split:
+ symlink_file.name = "%s" % path.strip()
+ target_tarball_rootfs.addfile(symlink_file)
+ else:
+ symlink_file.name = "rootfs/%s" % path.strip()
+ target_tarball.addfile(symlink_file)
+
+ # Add directories
+ for path in ("dev", "mnt", "proc", "root", "sys", "tmp"):
+ directory_file = tarfile.TarInfo()
+ directory_file.type = tarfile.DIRTYPE
+ if split:
+ directory_file.name = "%s" % path
+ target_tarball_rootfs.addfile(directory_file)
+ else:
+ directory_file.name = "rootfs/%s" % path
+ target_tarball.addfile(directory_file)
+
+ # Add the metadata file
+ metadata_yaml = json.dumps(metadata, sort_keys=True,
+ indent=4, separators=(',', ': '),
+ ensure_ascii=False).encode('utf-8') + b"\n"
+
+ metadata_file = tarfile.TarInfo()
+ metadata_file.size = len(metadata_yaml)
+ metadata_file.name = "metadata.yaml"
+ target_tarball.addfile(metadata_file,
+ io.BytesIO(metadata_yaml))
+
+ # Add an /etc/inittab; this is to work around:
+ # http://lists.busybox.net/pipermail/busybox/2015-November/083618.html
+ # Basically, since there are some hardcoded defaults that misbehave, we
+ # just pass an empty inittab so those aren't applied, and then busybox
+ # doesn't spin forever.
+ inittab = tarfile.TarInfo()
+ inittab.size = 1
+ inittab.name = "/rootfs/etc/inittab"
+ target_tarball.addfile(inittab, io.BytesIO(b"\n"))
+
+ target_tarball.close()
+ if split:
+ target_tarball_rootfs.close()
+
+ # Compress the tarball
+ r = subprocess.call([xz, "-9", destination_tar])
+ if r:
+ raise Exception("Failed to compress: %s" % destination_tar)
+
+ if split:
+ r = subprocess.call([xz, "-9", destination_tar_rootfs])
+ if r:
+ raise Exception("Failed to compress: %s" %
+ destination_tar_rootfs)
+ return destination_tar + ".xz", destination_tar_rootfs + ".xz"
+ else:
+ return destination_tar + ".xz"
+
+
+def create_busybox_image():
+ busybox = Busybox()
+ atexit.register(busybox.cleanup)
+
+ path = busybox.create_tarball()
+
+ with open(path, "rb") as fd:
+ fingerprint = hashlib.sha256(fd.read()).hexdigest()
+
+ return path, fingerprint
diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
index c376870..e66541e 100644
--- a/integration/test_1_0_containers.py
+++ b/integration/test_1_0_containers.py
@@ -26,9 +26,7 @@ def test_1_0_containers(self):
def test_1_0_containers_POST(self):
"""Return: background operation or standard error."""
- name = self.create_container()
- self.addCleanup(self.delete_container, name)
-
+ name = self.id().split('.')[-1].replace('_', '')
machine = {
'name': name,
'architecture': 2,
@@ -39,6 +37,7 @@ def test_1_0_containers_POST(self):
'alias': 'busybox'},
}
result = self.lxd['1.0']['containers'].post(json=machine)
+ self.addCleanup(self.delete_container, name, enforce=True)
# self.assertCommon(result)
self.assertEqual(202, result.status_code)
@@ -50,7 +49,6 @@ class ContainerTestCase(IntegrationTestCase):
def setUp(self):
super(ContainerTestCase, self).setUp()
self.name = self.create_container()
- self.addCleanup(self.delete_container, self.name)
class Test10Container(ContainerTestCase):
@@ -76,7 +74,7 @@ def test10container_post(self):
result = self.lxd['1.0']['containers'][self.name].post(json={
'name': new_name,
})
- self.addCleanup(self.delete_container, new_name)
+ self.addCleanup(self.delete_container, new_name, enforce=True)
self.assertEqual(202, result.status_code)
@@ -187,28 +185,36 @@ def setUp(self):
'action': 'start', 'timeout': 30, 'force': True})
operation_uuid = result.json()['operation'].split('/')[-1]
self.lxd['1.0'].operations[operation_uuid].wait.get()
+ self.addCleanup(self.stop_container)
- def test10containerexec(self):
- """Return: background operation + optional websocket information.
-
- ...or standard error."""
- result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
- 'command': ['/bin/bash'],
- 'wait-for-websocket': False,
- 'interactive': True,
- })
+ def stop_container(self):
+ """Stop the container (before deleting it)."""
+ result = self.lxd['1.0'].containers[self.name].state.put(json={
+ 'action': 'stop', 'timeout': 30, 'force': True})
+ operation_uuid = result.json()['operation'].split('/')[-1]
+ self.lxd['1.0'].operations[operation_uuid].wait.get()
- self.assertEqual(202, result.status_code)
+# def test10containerexec(self):
+# """Return: background operation + optional websocket information.
+#
+# ...or standard error."""
+# result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
+# 'command': ['/bin/bash'],
+# 'wait-for-websocket': False,
+# 'interactive': True,
+# })
+#
+# self.assertEqual(202, result.status_code)
class Test10ContainerLogs(ContainerTestCase):
"""Tests for /1.0/containers/<name>/logs."""
- def test10containerlogs(self):
- """Return: a list of the available log files."""
- result = self.lxd['1.0'].containers[self.name].logs.get()
-
- self.assertEqual(200, result.status_code)
+# def test10containerlogs(self):
+# """Return: a list of the available log files."""
+# result = self.lxd['1.0'].containers[self.name].logs.get()
+#
+# self.assertEqual(200, result.status_code)
class Test10ContainerLog(ContainerTestCase):
@@ -219,14 +225,14 @@ def setUp(self):
result = self.lxd['1.0'].containers[self.name].logs.get()
self.log_name = result.json()['metadata'][0]['name']
- def test10containerlog(self):
- """Return: the contents of the log file."""
- result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
-
- self.assertEqual(200, result.status_code)
-
- def test10containerlog_DELETE(self):
- """Return: the contents of the log file."""
- result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
-
- self.assertEqual(200, result.status_code)
+# def test10containerlog(self):
+# """Return: the contents of the log file."""
+# result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
+#
+# self.assertEqual(200, result.status_code)
+#
+# def test10containerlog_DELETE(self):
+# """Return: the contents of the log file."""
+# result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
+#
+# self.assertEqual(200, result.status_code)
diff --git a/integration/test_1_0_events.py b/integration/test_1_0_events.py
new file mode 100644
index 0000000..f95831e
--- /dev/null
+++ b/integration/test_1_0_events.py
@@ -0,0 +1,27 @@
+# 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
+
+
+class Test10Events(IntegrationTestCase):
+ """Tests for /1.0/events."""
+
+ def test_1_0_events(self):
+ """Return: none (never ending flow of events)."""
+ # XXX: rockstar (14 Jan 2016) - This returns a 400 in pylxd, because
+ # websockets. I plan to sort this integration test out later, but nova-lxd
+ # does not use websockets, so I'll wait a bit on that.
+ result = self.lxd['1.0']['events'].get(params={'type': 'operation,logging'})
+
+ self.assertEqual(400, result.status_code)
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
new file mode 100644
index 0000000..2ab3f51
--- /dev/null
+++ b/integration/test_1_0_images.py
@@ -0,0 +1,127 @@
+# 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.
+import unittest
+
+from integration.testing import IntegrationTestCase
+
+
+class Test10Images(IntegrationTestCase):
+ """Tests for /1.0/images"""
+
+ def test_1_0_images(self):
+ """Return: list of URLs for images this server publishes."""
+ response = self.lxd['1.0'].images.get()
+
+ self.assertEqual(200, response.status_code)
+
+ def test_1_0_images_POST(self):
+ """Return: list of URLs for images this server publishes."""
+ response = self.lxd['1.0'].images.post(json={
+ 'public': True,
+ 'source': {
+ 'type': 'url',
+ 'server': 'http://example.com',
+ 'alias': 'test-image'
+ }})
+
+ self.assertEqual(202, response.status_code)
+
+
+class ImageTestCase(IntegrationTestCase):
+ """An Image test case."""
+
+ def setUp(self):
+ super(ImageTestCase, self).setUp()
+ self.fingerprint = self.create_image()
+
+
+class Test10Image(ImageTestCase):
+ """Tests for /1.0/images/<fingerprint>."""
+
+ def test_1_0_images_name(self):
+ """Return: dict representing an image properties."""
+ response = self.lxd['1.0'].images[self.fingerprint].get()
+
+ self.assertEqual(200, response.status_code)
+
+ def test_1_0_images_name_DELETE(self):
+ """Return: dict representing an image properties."""
+ response = self.lxd['1.0'].images[self.fingerprint].delete()
+
+ self.assertEqual(200, response.status_code)
+
+ @unittest.skip("Not yet implemented in LXD")
+ def test_1_0_images_name_POST(self):
+ """Return: dict representing an image properties."""
+ response = self.lxd['1.0'].images[self.fingerprint].post(json={
+ 'name': 'test-image'
+ })
+
+ self.assertEqual(200, response.status_code)
+
+ def test_1_0_images_name_PUT(self):
+ """Return: dict representing an image properties."""
+ response = self.lxd['1.0'].images[self.fingerprint].put(json={
+ 'public': False
+ })
+
+ self.assertEqual(200, response.status_code)
+
+
+class Test10ImageExport(ImageTestCase):
+ """Tests for /1.0/images/<fingerprint>/export."""
+
+ def test_1_0_images_export(self):
+ """Return: dict representing an image properties."""
+ response = self.lxd['1.0'].images[self.fingerprint].export.get()
+
+ self.assertEqual(200, response.status_code)
+
+
+class Test10ImageSecret(ImageTestCase):
+ """Tests for /1.0/images/<fingerprint>/secret."""
+
+ def test_1_0_images_secret(self):
+ """Return: dict representing an image properties."""
+ response = self.lxd['1.0'].images[self.fingerprint].secret.post({
+ 'name': 'abcdef'
+ })
+
+ self.assertEqual(202, response.status_code)
+
+
+class Test10ImageAliases(IntegrationTestCase):
+ """Tests for /1.0/images/aliases."""
+
+ def test_1_0_images_aliases(self):
+ """Return: list of URLs for images this server publishes."""
+ response = self.lxd['1.0'].images.aliases.get()
+
+ self.assertEqual(200, response.status_code)
+
+ def test_1_0_images_aliases_POST(self):
+ """Return: list of URLs for images this server publishes."""
+ fingerprint = self.create_image()
+ alias = 'test-alias'
+ self.addCleanup(self.delete_image, alias)
+ response = self.lxd['1.0'].images.aliases.post(json={
+ 'target': fingerprint,
+ 'name': alias
+ })
+
+ self.assertEqual(200, response.status_code)
+
+
+class Test10ImageAlias(IntegrationTestCase):
+ """Tests for /1.0/images/aliases/<alias>."""
diff --git a/integration/testing.py b/integration/testing.py
index 29a3bc8..f06b336 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -12,9 +12,9 @@
# License for the specific language governing permissions and limitations
# under the License.
import unittest
-import uuid
from pylxd.api import LXD
+from integration.busybox import create_busybox_image
class IntegrationTestCase(unittest.TestCase):
@@ -26,7 +26,7 @@ def setUp(self):
def create_container(self):
"""Create a container in lxd."""
- name = 'a' + str(uuid.uuid4())
+ name = self.id().split('.')[-1].replace('_', '')
machine = {
'name': name,
'architecture': 2,
@@ -38,12 +38,45 @@ def create_container(self):
}
result = self.lxd['1.0']['containers'].post(json=machine)
operation_uuid = result.json()['operation'].split('/')[-1]
- self.lxd['1.0'].operations[operation_uuid].wait.get()
+ result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+ self.addCleanup(self.delete_container, name)
return name
- def delete_container(self, name):
+ def delete_container(self, name, enforce=False):
"""Delete a container in lxd."""
- self.lxd['1.0']['containers'][name].delete()
+ #response = self.lxd['1.0'].containers['name'].get()
+ #if response == 200:
+ # enforce is a hack. There's a race somewhere in the delete.
+ # To ensure we don't get an infinite loop, let's count.
+ count = 0
+ result = self.lxd['1.0']['containers'][name].delete()
+ while enforce and result.status_code == 404 and count < 10:
+ result = self.lxd['1.0']['containers'][name].delete()
+ count += 1
+ try:
+ operation_uuid = result.json()['operation'].split('/')[-1]
+ result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+ except KeyError:
+ pass # 404 cases are okay.
+
+ def create_image(self):
+ """Create an image in lxd."""
+ path, fingerprint = create_busybox_image()
+ with open(path, 'rb') as f:
+ headers = {
+ 'X-LXD-Public': '1',
+ }
+ response = self.lxd['1.0'].images.post(data=f.read(), headers=headers)
+ operation_uuid = response.json()['operation'].split('/')[-1]
+ self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+ self.addCleanup(self.delete_image, fingerprint)
+ return fingerprint
+
+ def delete_image(self, fingerprint):
+ """Delete an image in lxd."""
+ self.lxd['1.0'].images[fingerprint].delete()
def assertCommon(self, response):
"""Assert common LXD responses.
From 73d5f7d9c5e1f193c9dd24acf363612f6771fa44 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sun, 14 Feb 2016 21:21:46 -0700
Subject: [PATCH 03/20] Initial skeleton for 2.0 api.
---
integration/test_containers.py | 53 ++++++++++++++++++
pylxd/client.py | 122 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 175 insertions(+)
create mode 100644 integration/test_containers.py
create mode 100644 pylxd/client.py
diff --git a/integration/test_containers.py b/integration/test_containers.py
new file mode 100644
index 0000000..29c89bd
--- /dev/null
+++ b/integration/test_containers.py
@@ -0,0 +1,53 @@
+# 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
+
+
+class TestContainers(IntegrationTestCase):
+ """Tests for `Client.containers`"""
+
+ def test_get(self):
+ """A container is fetched by name."""
+ name = self.create_container()
+ self.addCleanup(self.delete_container, name)
+
+ container = self.client.containers.get(name)
+
+ self.assertEqual(name, container.name)
+
+ def test_all(self):
+ """A list of all containers is returned."""
+ name = self.create_container()
+ self.addCleanup(self.delete_container, name)
+
+ containers = self.client.containers.all()
+
+ self.assertEqual(1, len(containers))
+ self.assertEqual(name, containers[0].name)
+
+ def test_create(self):
+ """Creates and returns a new container."""
+ config = {
+ 'name': 'an-container',
+ 'architecture': 2,
+ 'profiles': ['default'],
+ 'ephemeral': True,
+ 'config': {'limits.cpu': '2'},
+ 'source': {'type': 'image',
+ 'alias': 'busybox'},
+ }
+
+ container = self.client.containers.create(config)
+
+ self.assertEqual(config.name, container.name)
diff --git a/pylxd/client.py b/pylxd/client.py
new file mode 100644
index 0000000..5a329ae
--- /dev/null
+++ b/pylxd/client.py
@@ -0,0 +1,122 @@
+# 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.
+import os
+
+import requests
+import requests_unixsocket
+
+
+class _APINode(object):
+ """An api node object.
+
+ This class allows us to dynamically create request urls by expressing them
+ in python. For example:
+
+ >>> node = _APINode('http://example.com/api')
+ >>> node.users[1].comments.get()
+
+ ...would make an HTTP GET request on
+ http://example.com/api/users/1/comments
+ """
+
+ def __init__(self, api_endpoint):
+ self._api_endpoint = api_endpoint
+
+ def __getattr__(self, name):
+ return self.__class__('{}/{}'.format(self._api_endpoint, name))
+
+ 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
+
+ def get(self, *args, **kwargs):
+ """Perform an HTTP GET."""
+ return self.session.get(self._api_endpoint, *args, **kwargs)
+
+ def post(self, *args, **kwargs):
+ """Perform an HTTP POST."""
+ return self.session.post(self._api_endpoint, *args, **kwargs)
+
+ def put(self, *args, **kwargs):
+ """Perform an HTTP PUT."""
+ return self.session.put(self._api_endpoint, *args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ """Perform an HTTP delete."""
+ return self.session.delete(self._api_endpoint, *args, **kwargs)
+
+
+class Client(object):
+ """A LXD client."""
+
+ def __init__(self, endpoint=None):
+ if endpoint is not None:
+ self.api = _APINode(endpoint)['1.0']
+ else:
+ if 'LXD_DIR' in os.environ:
+ path = os.path.join(
+ os.environ.get['LXD_DIR'], 'unix.socket')
+ else:
+ path = '/var/lib/lxd/unix.socket'
+ self._api = _APINode('http+unix://{}'.format(path))
+
+ self.containers = _Containers(self)
+ self.images = _Images(self)
+ self.profiles = _Profiles(self)
+ self.operations = _Operations(self)
+
+
+class _Containers(object):
+ """A wrapper for working with containers."""
+
+ def __init__(self, client):
+ self._client = client
+
+ def get(self, name):
+ pass
+
+ def all(self):
+ response = self.client.api.containers.get()
+
+ return []
+
+ def create(self, config):
+ pass
+
+
+class _Images(object):
+ """A wrapper for working with images."""
+
+ def __init__(self, client):
+ self._client = client
+
+
+class _Profiles(object):
+ """A wrapper for working with profiles."""
+
+ def __init__(self, client):
+ self._client = client
+
+
+class _Operations(object):
+ """A wrapper for working with operations."""
+
+ def __init__(self, client):
+ self._client = client
From 93f814757d667ecb5958c5ab9a21c2eb295082d6 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sun, 14 Feb 2016 21:23:35 -0700
Subject: [PATCH 04/20] Move _APINode
---
pylxd/api.py | 51 ---------------------------------------------------
1 file changed, 51 deletions(-)
diff --git a/pylxd/api.py b/pylxd/api.py
index f9a1370..d1be4c4 100644
--- a/pylxd/api.py
+++ b/pylxd/api.py
@@ -12,10 +12,6 @@
# 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 requests
-import requests_unixsocket
-
from pylxd import certificate
from pylxd import connection
from pylxd import container
@@ -26,53 +22,6 @@
from pylxd import profiles
-class _APINode(object):
- """An api node object.
-
- This class allows us to dynamically create request urls by expressing them
- in python. For example:
-
- >>> node = APINode('http://example.com/api')
- >>> node.users[1].comments.get()
-
- ...would make an HTTP GET request on
- http://example.com/api/users/1/comments
- """
-
- def __init__(self, api_endpoint):
- self._api_endpoint = api_endpoint
-
- def __getattr__(self, name):
- return self.__class__('{}/{}'.format(self._api_endpoint, name))
-
- 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
-
- def get(self, *args, **kwargs):
- """Perform an HTTP GET."""
- return self.session.get(self._api_endpoint, *args, **kwargs)
-
- def post(self, *args, **kwargs):
- """Perform an HTTP POST."""
- return self.session.post(self._api_endpoint, *args, **kwargs)
-
- def put(self, *args, **kwargs):
- """Perform an HTTP PUT."""
- return self.session.put(self._api_endpoint, *args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Perform an HTTP delete."""
- return self.session.delete(self._api_endpoint, *args, **kwargs)
-LXD = _APINode
-
-
class API(object):
def __init__(self, host=None, port=8443):
From 50ba5474b1bef2d655cbaf08db238d4cb6e86570 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sun, 14 Feb 2016 21:25:35 -0700
Subject: [PATCH 05/20] Fix integration tests.
---
integration/testing.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/integration/testing.py b/integration/testing.py
index f06b336..f9be74d 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -13,7 +13,7 @@
# under the License.
import unittest
-from pylxd.api import LXD
+from pylxd.client import Client
from integration.busybox import create_busybox_image
@@ -22,7 +22,8 @@ class IntegrationTestCase(unittest.TestCase):
def setUp(self):
super(IntegrationTestCase, self).setUp()
- self.lxd = LXD('http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket')
+ self.client = Client()
+ self.lxd = self.client.api
def create_container(self):
"""Create a container in lxd."""
From ad3d0058b0bcb10327442d7c1947cae9503ca600 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 11:08:27 -0700
Subject: [PATCH 06/20] Get the first of the integration tests working.
---
integration/test_containers.py | 5 +--
integration/testing.py | 18 +++++-----
pylxd/client.py | 82 +++++++++++++++++++++++++++++++++++-------
3 files changed, 82 insertions(+), 23 deletions(-)
diff --git a/integration/test_containers.py b/integration/test_containers.py
index 29c89bd..a76c61d 100644
--- a/integration/test_containers.py
+++ b/integration/test_containers.py
@@ -47,7 +47,8 @@ def test_create(self):
'source': {'type': 'image',
'alias': 'busybox'},
}
+ self.addCleanup(self.delete_container, config['name'])
- container = self.client.containers.create(config)
+ container = self.client.containers.create(config, wait=True)
- self.assertEqual(config.name, container.name)
+ self.assertEqual(config['name'], container.name)
diff --git a/integration/testing.py b/integration/testing.py
index f9be74d..f102b94 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -37,27 +37,27 @@ def create_container(self):
'source': {'type': 'image',
'alias': 'busybox'},
}
- result = self.lxd['1.0']['containers'].post(json=machine)
+ result = self.lxd['containers'].post(json=machine)
operation_uuid = result.json()['operation'].split('/')[-1]
- result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+ result = self.lxd.operations[operation_uuid].wait.get()
self.addCleanup(self.delete_container, name)
return name
def delete_container(self, name, enforce=False):
"""Delete a container in lxd."""
- #response = self.lxd['1.0'].containers['name'].get()
+ #response = self.lxd.containers['name'].get()
#if response == 200:
# enforce is a hack. There's a race somewhere in the delete.
# To ensure we don't get an infinite loop, let's count.
count = 0
- result = self.lxd['1.0']['containers'][name].delete()
+ result = self.lxd['containers'][name].delete()
while enforce and result.status_code == 404 and count < 10:
- result = self.lxd['1.0']['containers'][name].delete()
+ result = self.lxd['containers'][name].delete()
count += 1
try:
operation_uuid = result.json()['operation'].split('/')[-1]
- result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+ result = self.lxd.operations[operation_uuid].wait.get()
except KeyError:
pass # 404 cases are okay.
@@ -68,16 +68,16 @@ def create_image(self):
headers = {
'X-LXD-Public': '1',
}
- response = self.lxd['1.0'].images.post(data=f.read(), headers=headers)
+ response = self.lxd.images.post(data=f.read(), headers=headers)
operation_uuid = response.json()['operation'].split('/')[-1]
- self.lxd['1.0'].operations[operation_uuid].wait.get()
+ self.lxd.operations[operation_uuid].wait.get()
self.addCleanup(self.delete_image, fingerprint)
return fingerprint
def delete_image(self, fingerprint):
"""Delete an image in lxd."""
- self.lxd['1.0'].images[fingerprint].delete()
+ self.lxd.images[fingerprint].delete()
def assertCommon(self, response):
"""Assert common LXD responses.
diff --git a/pylxd/client.py b/pylxd/client.py
index 5a329ae..e5f5399 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -12,10 +12,13 @@
# License for the specific language governing permissions and limitations
# under the License.
import os
+import urllib
import requests
import requests_unixsocket
+requests_unixsocket.monkeypatch()
+
class _APINode(object):
"""An api node object.
@@ -66,16 +69,18 @@ def delete(self, *args, **kwargs):
class Client(object):
"""A LXD client."""
- def __init__(self, endpoint=None):
+ def __init__(self, endpoint=None, version='1.0'):
if endpoint is not None:
- self.api = _APINode(endpoint)['1.0']
+ self.api = _APINode(endpoint)
else:
if 'LXD_DIR' in os.environ:
path = os.path.join(
os.environ.get['LXD_DIR'], 'unix.socket')
else:
path = '/var/lib/lxd/unix.socket'
- self._api = _APINode('http+unix://{}'.format(path))
+ self.api = _APINode('http+unix://{}'.format(
+ urllib.quote(path, safe='')))
+ self.api = self.api[version]
self.containers = _Containers(self)
self.images = _Images(self)
@@ -83,40 +88,93 @@ def __init__(self, endpoint=None):
self.operations = _Operations(self)
-class _Containers(object):
+class Waitable(object):
+
+ def wait_for_operation(self, operation_id):
+ operation = self._client.operations.get(operation_id)
+ operation.wait()
+
+
+class _Containers(Waitable):
"""A wrapper for working with containers."""
def __init__(self, client):
self._client = client
def get(self, name):
- pass
+ response = self._client.api.containers[name].get()
+
+ container = Container(**response.json()['metadata'])
+ return container
def all(self):
- response = self.client.api.containers.get()
+ response = self._client.api.containers.get()
- return []
+ containers = []
+ for url in response.json()['metadata']:
+ name = url.split('/')[-1]
+ containers.append(Container(name=name))
+ return containers
- def create(self, config):
- pass
+ def create(self, config, wait=False):
+ response = self._client.api.containers.post(json=config)
+ if wait:
+ operation_id = response.json()['operation'].split('/')[-1]
+ self.wait_for_operation(operation_id)
+ return Container(name=config['name'])
-class _Images(object):
+
+class _Images(Waitable):
"""A wrapper for working with images."""
def __init__(self, client):
self._client = client
-class _Profiles(object):
+class _Profiles(Waitable):
"""A wrapper for working with profiles."""
def __init__(self, client):
self._client = client
-class _Operations(object):
+class _Operations(Waitable):
"""A wrapper for working with operations."""
def __init__(self, client):
self._client = client
+
+ def get(self, operation_id):
+ response = self._client.api.operations[operation_id].get()
+
+ return Operation(_client=self._client, **response.json()['metadata'])
+
+
+class Container(object):
+
+ __slots__ = [
+ 'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
+ 'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
+ ]
+
+ def __init__(self, **kwargs):
+ super(Container, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+
+class Operation(object):
+
+ __slots__ = [
+ '_client',
+ 'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata', 'resources',
+ 'status', 'status_code', 'updated_at']
+
+ def __init__(self, **kwargs):
+ super(Operation, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+ def wait(self):
+ self._client.api.operations[self.id].wait.get()
From ccd62e905004fcb0efe722df1943b117d5203014 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 11:09:20 -0700
Subject: [PATCH 07/20] Remove redundant tests.
---
integration/test_1_0_containers.py | 29 -----------------------------
1 file changed, 29 deletions(-)
diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
index e66541e..4909284 100644
--- a/integration/test_1_0_containers.py
+++ b/integration/test_1_0_containers.py
@@ -14,35 +14,6 @@
from integration.testing import IntegrationTestCase
-class Test10Containers(IntegrationTestCase):
- """Tests for /1.0/containers"""
-
- def test_1_0_containers(self):
- """Return: list of URLs for containers this server publishes."""
- result = self.lxd['1.0']['containers'].get()
-
- self.assertCommon(result)
- self.assertEqual(200, result.status_code)
-
- def test_1_0_containers_POST(self):
- """Return: background operation or standard error."""
- name = self.id().split('.')[-1].replace('_', '')
- machine = {
- 'name': name,
- 'architecture': 2,
- 'profiles': ['default'],
- 'ephemeral': True,
- 'config': {'limits.cpu': '2'},
- 'source': {'type': 'image',
- 'alias': 'busybox'},
- }
- result = self.lxd['1.0']['containers'].post(json=machine)
- self.addCleanup(self.delete_container, name, enforce=True)
-
- # self.assertCommon(result)
- self.assertEqual(202, result.status_code)
-
-
class ContainerTestCase(IntegrationTestCase):
"""A Container-specific test case."""
From a851d3372f67d818db8484a6566b48c33da8ae19 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 14:01:22 -0700
Subject: [PATCH 08/20] Fully functional Container class
---
integration/test_1_0_containers.py | 209 -------------------------------------
integration/test_containers.py | 93 +++++++++++++++++
integration/testing.py | 2 +-
pylxd/client.py | 137 +++++++++++++++++++++++-
4 files changed, 226 insertions(+), 215 deletions(-)
delete mode 100644 integration/test_1_0_containers.py
diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
deleted file mode 100644
index 4909284..0000000
--- a/integration/test_1_0_containers.py
+++ /dev/null
@@ -1,209 +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.
-from integration.testing import IntegrationTestCase
-
-
-class ContainerTestCase(IntegrationTestCase):
- """A Container-specific test case."""
-
- def setUp(self):
- super(ContainerTestCase, self).setUp()
- self.name = self.create_container()
-
-
-class Test10Container(ContainerTestCase):
- """Tests for /1.0/containers/<name>"""
-
- def test10container(self):
- """Return: dict of the container configuration and current state."""
- result = self.lxd['1.0']['containers'][self.name].get()
-
- self.assertEqual(200, result.status_code)
-
- def test10container_put(self):
- """Return: background operation or standard error."""
- result = self.lxd['1.0']['containers'][self.name].put(json={
- 'config': {'limits.cpu': '1'}
- })
-
- self.assertEqual(202, result.status_code)
-
- def test10container_post(self):
- """Return: background operation or standard error."""
- new_name = 'newname'
- result = self.lxd['1.0']['containers'][self.name].post(json={
- 'name': new_name,
- })
- self.addCleanup(self.delete_container, new_name, enforce=True)
-
- self.assertEqual(202, result.status_code)
-
- def test10container_delete(self):
- """Return: background operation or standard error."""
- result = self.lxd['1.0']['containers'][self.name].delete()
-
- self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerState(ContainerTestCase):
- """Tests for /1.0/containers/<name>/state."""
-
- def test10containerstate(self):
- """Return: dict representing current state."""
- result = self.lxd['1.0'].containers[self.name].state.get()
-
- self.assertEqual(200, result.status_code)
-
- def test10containerstate_PUT(self):
- """Return: background operation or standard error."""
- result = self.lxd['1.0'].containers[self.name].state.put(json={
- 'action': 'start',
- 'timeout': 30,
- 'force': True
- })
-
- self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerFiles(ContainerTestCase):
- """Tests for /1.0/containers/<name>/files."""
-
- def test10containerfiles(self):
- """Return: dict representing current files."""
- result = self.lxd['1.0'].containers[self.name].files.get(params={
- 'path': '/bin/sh'
- })
-
- self.assertEqual(200, result.status_code)
-
- def test10containerfiles_POST(self):
- """Return: standard return value or standard error."""
- result = self.lxd['1.0'].containers[self.name].files.get(params={
- 'path': '/bin/sh'
- }, data='abcdef')
-
- self.assertEqual(200, result.status_code)
-
-
-class Test10ContainerSnapshots(ContainerTestCase):
- """Tests for /1.0/containers/<name>/snapshots."""
-
- def test10containersnapshots(self):
- """Return: list of URLs for snapshots for this container."""
- result = self.lxd['1.0'].containers[self.name].snapshots.get()
-
- self.assertEqual(200, result.status_code)
-
- def test10containersnapshots_POST(self):
- """Return: background operation or standard error."""
- result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
- 'name': 'test-snapshot',
- 'stateful': False
- })
-
- self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerSnapshot(ContainerTestCase):
- """Tests for /1.0/containers/<name>/snapshot/<name>."""
-
- def setUp(self):
- super(Test10ContainerSnapshot, self).setUp()
- result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
- 'name': 'test-snapshot', 'stateful': False})
- operation_uuid = result.json()['operation'].split('/')[-1]
- result = self.lxd['1.0'].operations[operation_uuid].wait.get()
-
- def test10containersnapshot(self):
- """Return: dict representing the snapshot."""
- result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].get()
-
- self.assertEqual(200, result.status_code)
-
- def test10containersnapshot_POST(self):
- """Return: dict representing the snapshot."""
- result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].post(json={
- 'name': 'test-snapshot.bak-lol'
- })
-
- self.assertEqual(202, result.status_code)
-
- def test10containersnapshot_DELETE(self):
- """Return: dict representing the snapshot."""
- result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].delete()
-
- self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerExec(ContainerTestCase):
- """Tests for /1.0/containers/<name>/exec."""
-
- def setUp(self):
- super(Test10ContainerExec, self).setUp()
-
- result = self.lxd['1.0'].containers[self.name].state.put(json={
- 'action': 'start', 'timeout': 30, 'force': True})
- operation_uuid = result.json()['operation'].split('/')[-1]
- self.lxd['1.0'].operations[operation_uuid].wait.get()
- self.addCleanup(self.stop_container)
-
- def stop_container(self):
- """Stop the container (before deleting it)."""
- result = self.lxd['1.0'].containers[self.name].state.put(json={
- 'action': 'stop', 'timeout': 30, 'force': True})
- operation_uuid = result.json()['operation'].split('/')[-1]
- self.lxd['1.0'].operations[operation_uuid].wait.get()
-
-# def test10containerexec(self):
-# """Return: background operation + optional websocket information.
-#
-# ...or standard error."""
-# result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
-# 'command': ['/bin/bash'],
-# 'wait-for-websocket': False,
-# 'interactive': True,
-# })
-#
-# self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerLogs(ContainerTestCase):
- """Tests for /1.0/containers/<name>/logs."""
-
-# def test10containerlogs(self):
-# """Return: a list of the available log files."""
-# result = self.lxd['1.0'].containers[self.name].logs.get()
-#
-# self.assertEqual(200, result.status_code)
-
-
-class Test10ContainerLog(ContainerTestCase):
- """Tests for /1.0/containers/<name>/logs/<logfile>."""
-
- def setUp(self):
- super(Test10ContainerLog, self).setUp()
- result = self.lxd['1.0'].containers[self.name].logs.get()
- self.log_name = result.json()['metadata'][0]['name']
-
-# def test10containerlog(self):
-# """Return: the contents of the log file."""
-# result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
-#
-# self.assertEqual(200, result.status_code)
-#
-# def test10containerlog_DELETE(self):
-# """Return: the contents of the log file."""
-# result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
-#
-# self.assertEqual(200, result.status_code)
diff --git a/integration/test_containers.py b/integration/test_containers.py
index a76c61d..d72d4fb 100644
--- a/integration/test_containers.py
+++ b/integration/test_containers.py
@@ -52,3 +52,96 @@ def test_create(self):
container = self.client.containers.create(config, wait=True)
self.assertEqual(config['name'], container.name)
+
+
+class TestContainer(IntegrationTestCase):
+ """Tests for Client.Container."""
+
+ def setUp(self):
+ super(TestContainer, self).setUp()
+ name = self.create_container()
+ self.container = self.client.containers.get(name)
+
+ def tearDown(self):
+ super(TestContainer, self).tearDown()
+ self.delete_container(self.container.name)
+
+ def test_update(self):
+ """The container is updated to a new config."""
+ self.container.config['limits.cpu'] = '1'
+ self.container.update(wait=True)
+
+ self.assertEqual('1', self.container.config['limits.cpu'])
+ container = self.client.containers.get(self.container.name)
+ self.assertEqual('1', container.config['limits.cpu'])
+
+ def test_rename(self):
+ """The container is renamed."""
+ name = 'an-renamed-container'
+ self.container.rename(name, wait=True)
+
+ self.assertEqual(name, self.container.name)
+ container = self.client.containers.get(name)
+ self.assertEqual(name, container.name)
+
+ def test_delete(self):
+ """The container is deleted."""
+ self.container.delete(wait=True)
+
+ self.assertRaises(
+ NameError, self.client.containers.get, self.container.name)
+
+ def test_start_stop(self):
+ """The container is started and then stopped."""
+ # NOTE: rockstar (15 Feb 2016) - I don't care for the
+ # multiple assertions here, but it's a okay-ish way
+ # to test what we need.
+ self.container.start(wait=True)
+
+ self.assertEqual('Running', self.container.status['status'])
+ container = self.client.containers.get(self.container.name)
+ self.assertEqual('Running', container.status['status'])
+
+ self.container.stop(wait=True)
+
+ self.assertEqual('Stopped', self.container.status['status'])
+ container = self.client.containers.get(self.container.name)
+ self.assertEqual('Stopped', container.status['status'])
+
+ def test_snapshot(self):
+ """A container snapshot is made, renamed, and deleted."""
+ # NOTE: rockstar (15 Feb 2016) - Once again, multiple things
+ # asserted in the same test.
+ name = 'an-snapshot'
+ self.container.snapshot(name, wait=True)
+
+ self.assertEqual([name], self.container.list_snapshots())
+
+ new_name = 'an-other-snapshot'
+ self.container.rename_snapshot(name, new_name, wait=True)
+
+ self.assertEqual([new_name], self.container.list_snapshots())
+
+ self.container.delete_snapshot(new_name, wait=True)
+
+ self.assertEqual([], self.container.list_snapshots())
+
+ def test_put_get_file(self):
+ """A file is written to the container and then read."""
+ filepath = '/tmp/an_file'
+ data = 'abcdef'
+
+ retval = self.container.put_file(filepath, data)
+
+ self.assertTrue(retval)
+
+ contents = self.container.get_file(filepath)
+
+ self.assertEqual(data, contents)
+
+ def test_execute(self):
+ """A command is executed on the container."""
+ self.container.start(wait=True)
+ self.addCleanup(self.container.stop, wait=True)
+
+ self.container.execute('ls /')
diff --git a/integration/testing.py b/integration/testing.py
index f102b94..affc630 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -32,7 +32,7 @@ def create_container(self):
'name': name,
'architecture': 2,
'profiles': ['default'],
- 'ephemeral': True,
+ 'ephemeral': False,
'config': {'limits.cpu': '2'},
'source': {'type': 'image',
'alias': 'busybox'},
diff --git a/pylxd/client.py b/pylxd/client.py
index e5f5399..393059a 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -90,8 +90,13 @@ def __init__(self, endpoint=None, version='1.0'):
class Waitable(object):
+ def get_operation(self, operation_id):
+ if operation_id.startswith('/'):
+ operation_id = operation_id.split('/')[-1]
+ return self._client.operations.get(operation_id)
+
def wait_for_operation(self, operation_id):
- operation = self._client.operations.get(operation_id)
+ operation = self.get_operation(operation_id)
operation.wait()
@@ -104,7 +109,9 @@ def __init__(self, client):
def get(self, name):
response = self._client.api.containers[name].get()
- container = Container(**response.json()['metadata'])
+ if response.status_code == 404:
+ raise NameError('No container named "{}"'.format(name))
+ container = Container(_client=self._client, **response.json()['metadata'])
return container
def all(self):
@@ -151,9 +158,21 @@ def get(self, operation_id):
return Operation(_client=self._client, **response.json()['metadata'])
-class Container(object):
+class Marshallable(object):
+
+ def marshall(self):
+ marshalled = {}
+ for name in self.__slots__:
+ if name.startswith('_'):
+ continue
+ marshalled[name] = getattr(self, name)
+ return marshalled
+
+
+class Container(Waitable, Marshallable):
__slots__ = [
+ '_client',
'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
]
@@ -163,13 +182,121 @@ def __init__(self, **kwargs):
for key, value in kwargs.iteritems():
setattr(self, key, value)
+ def reload(self):
+ response = self._client.api.containers[self.name].get()
+ if response.status_code == 404:
+ raise NameError('Container named "{}" has gone away'.format(self.name))
+ for key, value in response.json()['metadata'].iteritems():
+ setattr(self, key, value)
+
+ def update(self, wait=False):
+ marshalled = self.marshall()
+ # These two properties are explicitly not allowed.
+ del marshalled['name']
+ del marshalled['status']
+
+ response = self._client.api.containers[self.name].put(
+ json=marshalled)
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def rename(self, name, wait=False):
+ response = self._client.api.containers[self.name].post(json={'name': name})
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+ self.name = name
+
+ def delete(self, wait=False):
+ response = self._client.api.containers[self.name].delete()
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def _set_state(self, state, timeout=30, force=True, wait=False):
+ response = self._client.api.containers[self.name].state.put(json={
+ 'action': state,
+ 'timeout': timeout,
+ 'force': force
+ })
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+ self.reload()
+
+ def start(self, timeout=30, force=True, wait=False):
+ return self._set_state('start', timeout=timeout, force=force, wait=wait)
+
+ def stop(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def restart(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def freeze(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def unfreeze(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def snapshot(self, name, stateful=False, wait=False):
+ response = self._client.api.containers[self.name].snapshots.post(json={
+ 'name': name, 'stateful': stateful})
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def list_snapshots(self):
+ response = self._client.api.containers[self.name].snapshots.get()
+ return [snapshot.split('/')[-1] for snapshot in response.json()['metadata']]
+
+ def rename_snapshot(self, old, new, wait=False):
+ response = self._client.api.containers[self.name].snapshots[old].post(json={
+ 'name': new
+ })
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def delete_snapshot(self, name, wait=False):
+ response = self._client.api.containers[self.name].snapshots[name].delete()
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def get_file(self, filepath):
+ response = self._client.api.containers[self.name].files.get(
+ params={'path': filepath})
+ if response.status_code == 500:
+ # XXX: rockstar (15 Feb 2016) - This should really return a 404.
+ # I blame LXD. :)
+ raise IOError('Error reading "{}"'.format(filepath))
+ return response.content
+
+ def put_file(self, filepath, data):
+ response = self._client.api.containers[self.name].files.post(
+ params={'path': filepath}, data=data)
+ return response.status_code == 200
+
+ def execute(self, commands, environment={}):
+ # XXX: rockstar (15 Feb 2016) - This functionality is limited by
+ # design, for now. It needs to grow the ability to return web sockets
+ # and perform interactive functions.
+ if type(commands) in [str, unicode]:
+ commands = [commands]
+ response = self._client.api.containers[self.name]['exec'].post(json={
+ 'command': commands,
+ 'environment': environment,
+ 'wait-for-websocket': False,
+ 'interactive': False,
+ })
+ operation_id = response.json()['operation']
+ self.wait_for_operation(operation_id)
+
class Operation(object):
__slots__ = [
'_client',
- 'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata', 'resources',
- 'status', 'status_code', 'updated_at']
+ 'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
+ 'resources', 'status', 'status_code', 'updated_at']
def __init__(self, **kwargs):
super(Operation, self).__init__()
From 57e610836297ef136b892ea1cdea5fe9109c45fa Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 15:14:27 -0700
Subject: [PATCH 09/20] Change the way that test objects are named.
---
integration/testing.py | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/integration/testing.py b/integration/testing.py
index affc630..735e63e 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -11,6 +11,7 @@
# 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 uuid
import unittest
from pylxd.client import Client
@@ -25,9 +26,14 @@ def setUp(self):
self.client = Client()
self.lxd = self.client.api
+ def generate_object_name(self):
+ test = self.id().split('.')[-1]
+ rando = str(uuid.uuid1()).split('-')[-1]
+ return '{}-{}'.format(test, rando)
+
def create_container(self):
"""Create a container in lxd."""
- name = self.id().split('.')[-1].replace('_', '')
+ name = self._generate_object_name()
machine = {
'name': name,
'architecture': 2,
@@ -46,8 +52,6 @@ def create_container(self):
def delete_container(self, name, enforce=False):
"""Delete a container in lxd."""
- #response = self.lxd.containers['name'].get()
- #if response == 200:
# enforce is a hack. There's a race somewhere in the delete.
# To ensure we don't get an infinite loop, let's count.
count = 0
@@ -72,8 +76,15 @@ def create_image(self):
operation_uuid = response.json()['operation'].split('/')[-1]
self.lxd.operations[operation_uuid].wait.get()
+ alias = self.generate_object_name()
+ response = self.lxd.images.aliases.post(json={
+ 'description': '',
+ 'target': fingerprint,
+ 'name': alias
+ })
+
self.addCleanup(self.delete_image, fingerprint)
- return fingerprint
+ return fingerprint, alias
def delete_image(self, fingerprint):
"""Delete an image in lxd."""
From 9e7c9fd02649e5755de8050a3b3a930656cace9b Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 15:22:30 -0700
Subject: [PATCH 10/20] Get the basic Images skeleton working
---
integration/test_1_0_images.py | 22 -------------------
integration/test_images.py | 46 ++++++++++++++++++++++++++++++++++++++++
pylxd/client.py | 48 +++++++++++++++++++++++++++++++++++++++++-
3 files changed, 93 insertions(+), 23 deletions(-)
create mode 100644 integration/test_images.py
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
index 2ab3f51..8a7fec8 100644
--- a/integration/test_1_0_images.py
+++ b/integration/test_1_0_images.py
@@ -16,28 +16,6 @@
from integration.testing import IntegrationTestCase
-class Test10Images(IntegrationTestCase):
- """Tests for /1.0/images"""
-
- def test_1_0_images(self):
- """Return: list of URLs for images this server publishes."""
- response = self.lxd['1.0'].images.get()
-
- self.assertEqual(200, response.status_code)
-
- def test_1_0_images_POST(self):
- """Return: list of URLs for images this server publishes."""
- response = self.lxd['1.0'].images.post(json={
- 'public': True,
- 'source': {
- 'type': 'url',
- 'server': 'http://example.com',
- 'alias': 'test-image'
- }})
-
- self.assertEqual(202, response.status_code)
-
-
class ImageTestCase(IntegrationTestCase):
"""An Image test case."""
diff --git a/integration/test_images.py b/integration/test_images.py
new file mode 100644
index 0000000..f5106f0
--- /dev/null
+++ b/integration/test_images.py
@@ -0,0 +1,46 @@
+# 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 create_busybox_image, IntegrationTestCase
+
+
+class TestImages(IntegrationTestCase):
+ """Tests for `Client.images.`"""
+
+ def test_get(self):
+ """An image is fetched by fingerprint."""
+ fingerprint, _ = self.create_image()
+ self.addCleanup(self.delete_image, fingerprint)
+
+ image = self.client.images.get(fingerprint)
+
+ self.assertEqual(fingerprint, image.fingerprint)
+
+ def test_all(self):
+ """A list of all images is returned."""
+ fingerprint, _ = self.create_image()
+ self.addCleanup(self.delete_image, fingerprint)
+
+ images = self.client.images.all()
+
+ self.assertIn(fingerprint, [image.fingerprint for image in images])
+
+ def test_create(self):
+ """An image is created."""
+ path, fingerprint = create_busybox_image()
+ self.addCleanup(self.delete_image, fingerprint)
+
+ with open(path) as f:
+ image = self.client.images.create(f.read(), wait=True)
+
+ self.assertEqual(fingerprint, image.fingerprint)
diff --git a/pylxd/client.py b/pylxd/client.py
index 393059a..5d795d8 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -11,6 +11,7 @@
# 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 hashlib
import os
import urllib
@@ -98,6 +99,7 @@ def get_operation(self, operation_id):
def wait_for_operation(self, operation_id):
operation = self.get_operation(operation_id)
operation.wait()
+ return operation
class _Containers(Waitable):
@@ -120,7 +122,7 @@ def all(self):
containers = []
for url in response.json()['metadata']:
name = url.split('/')[-1]
- containers.append(Container(name=name))
+ containers.append(Container(_client=self._client, name=name))
return containers
def create(self, config, wait=False):
@@ -138,6 +140,36 @@ class _Images(Waitable):
def __init__(self, client):
self._client = client
+ def get(self, fingerprint):
+ response = self._client.api.images[fingerprint].get()
+
+ if response.status_code == 404:
+ raise NameError('No image with fingerprint "{}"'.format(fingerprint))
+ image = Image(_client=self._client, **response.json()['metadata'])
+ return image
+
+ def all(self):
+ response = self._client.api.images.get()
+
+ images = []
+ for url in response.json()['metadata']:
+ fingerprint = url.split('/')[-1]
+ images.append(Image(_client=self._client, fingerprint=fingerprint))
+ return images
+
+ def create(self, image_data, public=False, wait=False):
+ fingerprint = hashlib.sha256(image_data).hexdigest()
+
+ headers = {}
+ if public:
+ headers['X-LXD-Public'] = '1'
+ response = self._client.api.images.post(
+ data=image_data, headers=headers)
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+ return self.get(fingerprint)
+
class _Profiles(Waitable):
"""A wrapper for working with profiles."""
@@ -169,6 +201,20 @@ def marshall(self):
return marshalled
+class Image(object):
+
+ __slots__ = [
+ '_client',
+ 'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
+ 'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
+ ]
+
+ def __init__(self, **kwargs):
+ super(Image, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+
class Container(Waitable, Marshallable):
__slots__ = [
From 1aedc9411dde3489381d6d25f9784e5cb7cec263 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 16:00:06 -0700
Subject: [PATCH 11/20] Add support for updating/deleting images
---
integration/test_1_0_images.py | 39 ---------------------------------------
integration/test_images.py | 29 +++++++++++++++++++++++++++++
pylxd/client.py | 12 +++++++++++-
3 files changed, 40 insertions(+), 40 deletions(-)
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
index 8a7fec8..91abc67 100644
--- a/integration/test_1_0_images.py
+++ b/integration/test_1_0_images.py
@@ -11,8 +11,6 @@
# 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 unittest
-
from integration.testing import IntegrationTestCase
@@ -24,39 +22,6 @@ def setUp(self):
self.fingerprint = self.create_image()
-class Test10Image(ImageTestCase):
- """Tests for /1.0/images/<fingerprint>."""
-
- def test_1_0_images_name(self):
- """Return: dict representing an image properties."""
- response = self.lxd['1.0'].images[self.fingerprint].get()
-
- self.assertEqual(200, response.status_code)
-
- def test_1_0_images_name_DELETE(self):
- """Return: dict representing an image properties."""
- response = self.lxd['1.0'].images[self.fingerprint].delete()
-
- self.assertEqual(200, response.status_code)
-
- @unittest.skip("Not yet implemented in LXD")
- def test_1_0_images_name_POST(self):
- """Return: dict representing an image properties."""
- response = self.lxd['1.0'].images[self.fingerprint].post(json={
- 'name': 'test-image'
- })
-
- self.assertEqual(200, response.status_code)
-
- def test_1_0_images_name_PUT(self):
- """Return: dict representing an image properties."""
- response = self.lxd['1.0'].images[self.fingerprint].put(json={
- 'public': False
- })
-
- self.assertEqual(200, response.status_code)
-
-
class Test10ImageExport(ImageTestCase):
"""Tests for /1.0/images/<fingerprint>/export."""
@@ -99,7 +64,3 @@ def test_1_0_images_aliases_POST(self):
})
self.assertEqual(200, response.status_code)
-
-
-class Test10ImageAlias(IntegrationTestCase):
- """Tests for /1.0/images/aliases/<alias>."""
diff --git a/integration/test_images.py b/integration/test_images.py
index f5106f0..7bf1450 100644
--- a/integration/test_images.py
+++ b/integration/test_images.py
@@ -44,3 +44,32 @@ def test_create(self):
image = self.client.images.create(f.read(), wait=True)
self.assertEqual(fingerprint, image.fingerprint)
+
+
+class TestImage(IntegrationTestCase):
+ """Tests for Image."""
+
+ def setUp(self):
+ super(TestImage, self).setUp()
+ fingerprint, _ = self.create_image()
+ self.image = self.client.images.get(fingerprint)
+
+ def tearDown(self):
+ super(TestImage, self).tearDown()
+ self.delete_image(self.image.fingerprint)
+
+ def test_update(self):
+ """The image properties are updated."""
+ description = 'an description'
+ self.image.properties['description'] = description
+ self.image.update()
+
+ image = self.client.images.get(self.image.fingerprint)
+ self.assertEqual(description, image.properties['description'])
+
+ def test_delete(self):
+ """The image is deleted."""
+ self.image.delete()
+
+ self.assertRaises(
+ NameError, self.client.images.get, self.image.fingerprint)
diff --git a/pylxd/client.py b/pylxd/client.py
index 5d795d8..3f7c1f1 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -201,7 +201,7 @@ def marshall(self):
return marshalled
-class Image(object):
+class Image(Waitable, Marshallable):
__slots__ = [
'_client',
@@ -214,6 +214,16 @@ def __init__(self, **kwargs):
for key, value in kwargs.iteritems():
setattr(self, key, value)
+ def update(self):
+ self._client.api.images[self.fingerprint].put(
+ json=self.marshall())
+
+ def delete(self, wait=False):
+ response = self._client.api.images[self.fingerprint].delete()
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
class Container(Waitable, Marshallable):
From 8082123a9c18ea6d3ef4be6dafb3d91ee00ec9a2 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 16:01:56 -0700
Subject: [PATCH 12/20] Update the create_image use for multiple return values
---
integration/test_1_0_images.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
index 91abc67..bdc8956 100644
--- a/integration/test_1_0_images.py
+++ b/integration/test_1_0_images.py
@@ -19,7 +19,7 @@ class ImageTestCase(IntegrationTestCase):
def setUp(self):
super(ImageTestCase, self).setUp()
- self.fingerprint = self.create_image()
+ self.fingerprint, _ = self.create_image()
class Test10ImageExport(ImageTestCase):
@@ -55,7 +55,7 @@ def test_1_0_images_aliases(self):
def test_1_0_images_aliases_POST(self):
"""Return: list of URLs for images this server publishes."""
- fingerprint = self.create_image()
+ fingerprint, _ = self.create_image()
alias = 'test-alias'
self.addCleanup(self.delete_image, alias)
response = self.lxd['1.0'].images.aliases.post(json={
From 3c3a562995e12c485bf8cca93438d762dd4420f1 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:20:17 -0700
Subject: [PATCH 13/20] Add tests and functionality for profiles
---
integration/test_profiles.py | 87 ++++++++++++++++++++++++++++++++++++++++++++
integration/testing.py | 12 ++++++
pylxd/client.py | 52 ++++++++++++++++++++++++++
3 files changed, 151 insertions(+)
create mode 100644 integration/test_profiles.py
diff --git a/integration/test_profiles.py b/integration/test_profiles.py
new file mode 100644
index 0000000..f5f1e00
--- /dev/null
+++ b/integration/test_profiles.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.
+import unittest
+
+from integration.testing import IntegrationTestCase
+
+
+class TestProfiles(IntegrationTestCase):
+ """Tests for `Client.profiles.`"""
+
+ def test_get(self):
+ """A profile is fetched by name."""
+ name = self.create_profile()
+ self.addCleanup(self.delete_profile, name)
+
+ profile = self.client.profiles.get(name)
+
+ self.assertEqual(name, profile.name)
+
+ def test_all(self):
+ """All profiles are fetched."""
+ name = self.create_profile()
+ self.addCleanup(self.delete_profile, name)
+
+ profiles = self.client.profiles.all()
+
+ self.assertIn(name, [profile.name for profile in profiles])
+
+ def test_create(self):
+ """A profile is created."""
+ name = 'an-profile'
+ config = {'limits.memory': '4GB'}
+ profile = self.client.profiles.create(name, config)
+ self.addCleanup(self.delete_profile, name)
+
+ self.assertEqual(name, profile.name)
+ self.assertEqual(config, profile.config)
+
+
+class TestProfile(IntegrationTestCase):
+ """Tests for `Profile`."""
+
+ def setUp(self):
+ super(TestProfile, self).setUp()
+ name = self.create_profile()
+ self.profile = self.client.profiles.get(name)
+
+ def tearDown(self):
+ super(TestProfile, self).tearDown()
+ self.delete_profile(self.profile.name)
+
+ def test_update(self):
+ """A profile is updated."""
+ self.profile.config['limits.memory'] = '16GB'
+ self.profile.update()
+
+ profile = self.client.profiles.get(self.profile.name)
+ self.assertEqual('16GB', profile.config['limits.memory'])
+
+ @unittest.skip('Not implemented in LXD')
+ def test_rename(self):
+ """A profile is renamed."""
+ name = 'a-other-profile'
+ self.addCleanup(self.delete_profile, name)
+
+ self.profile.rename(name)
+ profile = self.client.profiles.get(name)
+
+ self.assertEqual(name, profile.name)
+
+ def test_delete(self):
+ """A profile is deleted."""
+ self.profile.delete()
+
+ self.assertRaises(
+ NameError, self.client.profiles.get, self.profile.name)
diff --git a/integration/testing.py b/integration/testing.py
index 735e63e..01f0ee2 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -90,6 +90,18 @@ def delete_image(self, fingerprint):
"""Delete an image in lxd."""
self.lxd.images[fingerprint].delete()
+ def create_profile(self):
+ name = self.generate_object_name()
+ config = {'limits.memory': '1GB'}
+ self.lxd.profiles.post(json={
+ 'name': name,
+ 'config': config
+ })
+ return name
+
+ def delete_profile(self, name):
+ self.lxd.profiles[name].delete()
+
def assertCommon(self, response):
"""Assert common LXD responses.
diff --git a/pylxd/client.py b/pylxd/client.py
index 3f7c1f1..002b8f8 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -177,6 +177,30 @@ class _Profiles(Waitable):
def __init__(self, client):
self._client = client
+ def get(self, name):
+ response = self._client.api.profiles[name].get()
+
+ if response.status_code == 404:
+ raise NameError('No profile with name "{}"'.format(name))
+ return Profile(_client=self._client, **response.json()['metadata'])
+
+ def all(self):
+ response = self._client.api.profiles.get()
+
+ profiles = []
+ for url in response.json()['metadata']:
+ name = url.split('/')[-1]
+ profiles.append(Profile(_client=self._client, name=name))
+ return profiles
+
+ def create(self, name, config):
+ self._client.api.profiles.post(json={
+ 'name': name,
+ 'config': config
+ })
+
+ return self.get(name)
+
class _Operations(Waitable):
"""A wrapper for working with operations."""
@@ -201,6 +225,34 @@ def marshall(self):
return marshalled
+class Profile(Marshallable):
+
+ __slots__ = [
+ '_client',
+ 'config', 'devices', 'name'
+ ]
+
+ def __init__(self, **kwargs):
+ super(Profile, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+ def update(self):
+ marshalled = self.marshall()
+ # The name property cannot be updated.
+ del marshalled['name']
+
+ self._client.api.profiles[self.name].put(json=marshalled)
+
+ def rename(self, new):
+ raise NotImplementedError('LXD does not currently support renaming profiles')
+ self._client.api.profiles[self.name].post(json={'name': new})
+ self.name = new
+
+ def delete(self):
+ self._client.api.profiles[self.name].delete()
+
+
class Image(Waitable, Marshallable):
__slots__ = [
From a9fa54a6e6d0094aaefc199173f87278710f89e7 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:38:32 -0700
Subject: [PATCH 14/20] Fix the integration tests.
---
integration/test_1_0.py | 40 -------------------------
integration/test_1_0_events.py | 27 -----------------
integration/test_1_0_images.py | 66 ------------------------------------------
integration/test_root.py | 25 ----------------
integration/testing.py | 5 ++--
5 files changed, 3 insertions(+), 160 deletions(-)
delete mode 100644 integration/test_1_0.py
delete mode 100644 integration/test_1_0_events.py
delete mode 100644 integration/test_1_0_images.py
delete mode 100644 integration/test_root.py
diff --git a/integration/test_1_0.py b/integration/test_1_0.py
deleted file mode 100644
index 1218893..0000000
--- a/integration/test_1_0.py
+++ /dev/null
@@ -1,40 +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.
-from integration.testing import IntegrationTestCase
-
-
-class Test10(IntegrationTestCase):
- """Tests for /1.0"""
-
- def test_1_0(self):
- """Return: Dict representing server state."""
- result = self.lxd['1.0'].get()
-
- self.assertCommon(result)
- self.assertEqual(200, result.status_code)
- self.assertEqual(
- ['api_compat', 'auth', 'config', 'environment'],
- sorted(result.json()['metadata'].keys()))
- self.assertEqual(
- ['addresses', 'architectures', 'driver', 'driver_version', 'kernel',
- 'kernel_architecture', 'kernel_version', 'server', 'server_pid',
- 'server_version', 'storage', 'storage_version'],
- sorted(result.json()['metadata']['environment'].keys()))
-
- def test_1_0_PUT(self):
- """Return: standard return value or standard error."""
- result = self.lxd['1.0'].put(json={'config': {'core.trust_password': 'test'}})
-
- self.assertCommon(result)
- self.assertEqual(200, result.status_code)
diff --git a/integration/test_1_0_events.py b/integration/test_1_0_events.py
deleted file mode 100644
index f95831e..0000000
--- a/integration/test_1_0_events.py
+++ /dev/null
@@ -1,27 +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.
-from integration.testing import IntegrationTestCase
-
-
-class Test10Events(IntegrationTestCase):
- """Tests for /1.0/events."""
-
- def test_1_0_events(self):
- """Return: none (never ending flow of events)."""
- # XXX: rockstar (14 Jan 2016) - This returns a 400 in pylxd, because
- # websockets. I plan to sort this integration test out later, but nova-lxd
- # does not use websockets, so I'll wait a bit on that.
- result = self.lxd['1.0']['events'].get(params={'type': 'operation,logging'})
-
- self.assertEqual(400, result.status_code)
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
deleted file mode 100644
index bdc8956..0000000
--- a/integration/test_1_0_images.py
+++ /dev/null
@@ -1,66 +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.
-from integration.testing import IntegrationTestCase
-
-
-class ImageTestCase(IntegrationTestCase):
- """An Image test case."""
-
- def setUp(self):
- super(ImageTestCase, self).setUp()
- self.fingerprint, _ = self.create_image()
-
-
-class Test10ImageExport(ImageTestCase):
- """Tests for /1.0/images/<fingerprint>/export."""
-
- def test_1_0_images_export(self):
- """Return: dict representing an image properties."""
- response = self.lxd['1.0'].images[self.fingerprint].export.get()
-
- self.assertEqual(200, response.status_code)
-
-
-class Test10ImageSecret(ImageTestCase):
- """Tests for /1.0/images/<fingerprint>/secret."""
-
- def test_1_0_images_secret(self):
- """Return: dict representing an image properties."""
- response = self.lxd['1.0'].images[self.fingerprint].secret.post({
- 'name': 'abcdef'
- })
-
- self.assertEqual(202, response.status_code)
-
-
-class Test10ImageAliases(IntegrationTestCase):
- """Tests for /1.0/images/aliases."""
-
- def test_1_0_images_aliases(self):
- """Return: list of URLs for images this server publishes."""
- response = self.lxd['1.0'].images.aliases.get()
-
- self.assertEqual(200, response.status_code)
-
- def test_1_0_images_aliases_POST(self):
- """Return: list of URLs for images this server publishes."""
- fingerprint, _ = self.create_image()
- alias = 'test-alias'
- self.addCleanup(self.delete_image, alias)
- response = self.lxd['1.0'].images.aliases.post(json={
- 'target': fingerprint,
- 'name': alias
- })
-
- self.assertEqual(200, response.status_code)
diff --git a/integration/test_root.py b/integration/test_root.py
deleted file mode 100644
index aeae2ae..0000000
--- a/integration/test_root.py
+++ /dev/null
@@ -1,25 +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.
-from integration.testing import IntegrationTestCase
-
-
-class TestRoot(IntegrationTestCase):
- """Tests for /"""
-
- def test_root(self):
- """Return: list of supported API endpoint URLs."""
- result = self.lxd.get()
-
- self.assertCommon(result)
- self.assertEqual(200, result.status_code)
diff --git a/integration/testing.py b/integration/testing.py
index 01f0ee2..e6e3cfe 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -27,13 +27,14 @@ def setUp(self):
self.lxd = self.client.api
def generate_object_name(self):
- test = self.id().split('.')[-1]
+ # Underscores are not allowed in container names.
+ test = self.id().split('.')[-1].replace('_', '')
rando = str(uuid.uuid1()).split('-')[-1]
return '{}-{}'.format(test, rando)
def create_container(self):
"""Create a container in lxd."""
- name = self._generate_object_name()
+ name = self.generate_object_name()
machine = {
'name': name,
'architecture': 2,
From 16d9662383cd31342db55b5a42d491860d666cb0 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:46:03 -0700
Subject: [PATCH 15/20] Move Container to its final spot.
---
pylxd/client.py | 161 +++--------------------------------------------------
pylxd/container.py | 123 ++++++++++++++++++++++++++++++++++++++++
pylxd/mixin.py | 37 ++++++++++++
3 files changed, 169 insertions(+), 152 deletions(-)
create mode 100644 pylxd/mixin.py
diff --git a/pylxd/client.py b/pylxd/client.py
index 002b8f8..f9e3553 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -18,6 +18,9 @@
import requests
import requests_unixsocket
+from pylxd import mixin
+from pylxd.container import Container
+
requests_unixsocket.monkeypatch()
@@ -89,20 +92,7 @@ def __init__(self, endpoint=None, version='1.0'):
self.operations = _Operations(self)
-class Waitable(object):
-
- def get_operation(self, operation_id):
- if operation_id.startswith('/'):
- operation_id = operation_id.split('/')[-1]
- return self._client.operations.get(operation_id)
-
- def wait_for_operation(self, operation_id):
- operation = self.get_operation(operation_id)
- operation.wait()
- return operation
-
-
-class _Containers(Waitable):
+class _Containers(mixin.Waitable):
"""A wrapper for working with containers."""
def __init__(self, client):
@@ -134,7 +124,7 @@ def create(self, config, wait=False):
return Container(name=config['name'])
-class _Images(Waitable):
+class _Images(mixin.Waitable):
"""A wrapper for working with images."""
def __init__(self, client):
@@ -171,7 +161,7 @@ def create(self, image_data, public=False, wait=False):
return self.get(fingerprint)
-class _Profiles(Waitable):
+class _Profiles(mixin.Waitable):
"""A wrapper for working with profiles."""
def __init__(self, client):
@@ -202,7 +192,7 @@ def create(self, name, config):
return self.get(name)
-class _Operations(Waitable):
+class _Operations(mixin.Waitable):
"""A wrapper for working with operations."""
def __init__(self, client):
@@ -214,18 +204,7 @@ def get(self, operation_id):
return Operation(_client=self._client, **response.json()['metadata'])
-class Marshallable(object):
-
- def marshall(self):
- marshalled = {}
- for name in self.__slots__:
- if name.startswith('_'):
- continue
- marshalled[name] = getattr(self, name)
- return marshalled
-
-
-class Profile(Marshallable):
+class Profile(mixin.Marshallable):
__slots__ = [
'_client',
@@ -253,7 +232,7 @@ def delete(self):
self._client.api.profiles[self.name].delete()
-class Image(Waitable, Marshallable):
+class Image(mixin.Waitable, mixin.Marshallable):
__slots__ = [
'_client',
@@ -277,128 +256,6 @@ def delete(self, wait=False):
self.wait_for_operation(response.json()['operation'])
-class Container(Waitable, Marshallable):
-
- __slots__ = [
- '_client',
- 'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
- 'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
- ]
-
- def __init__(self, **kwargs):
- super(Container, self).__init__()
- for key, value in kwargs.iteritems():
- setattr(self, key, value)
-
- def reload(self):
- response = self._client.api.containers[self.name].get()
- if response.status_code == 404:
- raise NameError('Container named "{}" has gone away'.format(self.name))
- for key, value in response.json()['metadata'].iteritems():
- setattr(self, key, value)
-
- def update(self, wait=False):
- marshalled = self.marshall()
- # These two properties are explicitly not allowed.
- del marshalled['name']
- del marshalled['status']
-
- response = self._client.api.containers[self.name].put(
- json=marshalled)
-
- if wait:
- self.wait_for_operation(response.json()['operation'])
-
- def rename(self, name, wait=False):
- response = self._client.api.containers[self.name].post(json={'name': name})
-
- if wait:
- self.wait_for_operation(response.json()['operation'])
- self.name = name
-
- def delete(self, wait=False):
- response = self._client.api.containers[self.name].delete()
-
- if wait:
- self.wait_for_operation(response.json()['operation'])
-
- def _set_state(self, state, timeout=30, force=True, wait=False):
- response = self._client.api.containers[self.name].state.put(json={
- 'action': state,
- 'timeout': timeout,
- 'force': force
- })
- if wait:
- self.wait_for_operation(response.json()['operation'])
- self.reload()
-
- def start(self, timeout=30, force=True, wait=False):
- return self._set_state('start', timeout=timeout, force=force, wait=wait)
-
- def stop(self, timeout=30, force=True, wait=False):
- return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
- def restart(self, timeout=30, force=True, wait=False):
- return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
- def freeze(self, timeout=30, force=True, wait=False):
- return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
- def unfreeze(self, timeout=30, force=True, wait=False):
- return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
- def snapshot(self, name, stateful=False, wait=False):
- response = self._client.api.containers[self.name].snapshots.post(json={
- 'name': name, 'stateful': stateful})
- if wait:
- self.wait_for_operation(response.json()['operation'])
-
- def list_snapshots(self):
- response = self._client.api.containers[self.name].snapshots.get()
- return [snapshot.split('/')[-1] for snapshot in response.json()['metadata']]
-
- def rename_snapshot(self, old, new, wait=False):
- response = self._client.api.containers[self.name].snapshots[old].post(json={
- 'name': new
- })
- if wait:
- self.wait_for_operation(response.json()['operation'])
-
- def delete_snapshot(self, name, wait=False):
- response = self._client.api.containers[self.name].snapshots[name].delete()
- if wait:
- self.wait_for_operation(response.json()['operation'])
-
- def get_file(self, filepath):
- response = self._client.api.containers[self.name].files.get(
- params={'path': filepath})
- if response.status_code == 500:
- # XXX: rockstar (15 Feb 2016) - This should really return a 404.
- # I blame LXD. :)
- raise IOError('Error reading "{}"'.format(filepath))
- return response.content
-
- def put_file(self, filepath, data):
- response = self._client.api.containers[self.name].files.post(
- params={'path': filepath}, data=data)
- return response.status_code == 200
-
- def execute(self, commands, environment={}):
- # XXX: rockstar (15 Feb 2016) - This functionality is limited by
- # design, for now. It needs to grow the ability to return web sockets
- # and perform interactive functions.
- if type(commands) in [str, unicode]:
- commands = [commands]
- response = self._client.api.containers[self.name]['exec'].post(json={
- 'command': commands,
- 'environment': environment,
- 'wait-for-websocket': False,
- 'interactive': False,
- })
- operation_id = response.json()['operation']
- self.wait_for_operation(operation_id)
-
-
class Operation(object):
__slots__ = [
diff --git a/pylxd/container.py b/pylxd/container.py
index 94ddb34..6e44072 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -16,6 +16,7 @@
from pylxd import base
from pylxd import exceptions
+from pylxd import mixin
class LXDContainer(base.LXDBase):
@@ -204,3 +205,125 @@ def snapshot_delete(self, container, snapshot):
return self.connection.get_object('DELETE',
'/1.0/containers/%s/snapshots/%s'
% (container, snapshot))
+
+
+class Container(mixin.Waitable, mixin.Marshallable):
+
+ __slots__ = [
+ '_client',
+ 'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
+ 'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
+ ]
+
+ def __init__(self, **kwargs):
+ super(Container, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+ def reload(self):
+ response = self._client.api.containers[self.name].get()
+ if response.status_code == 404:
+ raise NameError('Container named "{}" has gone away'.format(self.name))
+ for key, value in response.json()['metadata'].iteritems():
+ setattr(self, key, value)
+
+ def update(self, wait=False):
+ marshalled = self.marshall()
+ # These two properties are explicitly not allowed.
+ del marshalled['name']
+ del marshalled['status']
+
+ response = self._client.api.containers[self.name].put(
+ json=marshalled)
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def rename(self, name, wait=False):
+ response = self._client.api.containers[self.name].post(json={'name': name})
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+ self.name = name
+
+ def delete(self, wait=False):
+ response = self._client.api.containers[self.name].delete()
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def _set_state(self, state, timeout=30, force=True, wait=False):
+ response = self._client.api.containers[self.name].state.put(json={
+ 'action': state,
+ 'timeout': timeout,
+ 'force': force
+ })
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+ self.reload()
+
+ def start(self, timeout=30, force=True, wait=False):
+ return self._set_state('start', timeout=timeout, force=force, wait=wait)
+
+ def stop(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def restart(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def freeze(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def unfreeze(self, timeout=30, force=True, wait=False):
+ return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+ def snapshot(self, name, stateful=False, wait=False):
+ response = self._client.api.containers[self.name].snapshots.post(json={
+ 'name': name, 'stateful': stateful})
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def list_snapshots(self):
+ response = self._client.api.containers[self.name].snapshots.get()
+ return [snapshot.split('/')[-1] for snapshot in response.json()['metadata']]
+
+ def rename_snapshot(self, old, new, wait=False):
+ response = self._client.api.containers[self.name].snapshots[old].post(json={
+ 'name': new
+ })
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def delete_snapshot(self, name, wait=False):
+ response = self._client.api.containers[self.name].snapshots[name].delete()
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
+
+ def get_file(self, filepath):
+ response = self._client.api.containers[self.name].files.get(
+ params={'path': filepath})
+ if response.status_code == 500:
+ # XXX: rockstar (15 Feb 2016) - This should really return a 404.
+ # I blame LXD. :)
+ raise IOError('Error reading "{}"'.format(filepath))
+ return response.content
+
+ def put_file(self, filepath, data):
+ response = self._client.api.containers[self.name].files.post(
+ params={'path': filepath}, data=data)
+ return response.status_code == 200
+
+ def execute(self, commands, environment={}):
+ # XXX: rockstar (15 Feb 2016) - This functionality is limited by
+ # design, for now. It needs to grow the ability to return web sockets
+ # and perform interactive functions.
+ if type(commands) in [str, unicode]:
+ commands = [commands]
+ response = self._client.api.containers[self.name]['exec'].post(json={
+ 'command': commands,
+ 'environment': environment,
+ 'wait-for-websocket': False,
+ 'interactive': False,
+ })
+ operation_id = response.json()['operation']
+ self.wait_for_operation(operation_id)
diff --git a/pylxd/mixin.py b/pylxd/mixin.py
new file mode 100644
index 0000000..5bd8960
--- /dev/null
+++ b/pylxd/mixin.py
@@ -0,0 +1,37 @@
+# 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.
+
+
+class Waitable(object):
+
+ def get_operation(self, operation_id):
+ if operation_id.startswith('/'):
+ operation_id = operation_id.split('/')[-1]
+ return self._client.operations.get(operation_id)
+
+ def wait_for_operation(self, operation_id):
+ operation = self.get_operation(operation_id)
+ operation.wait()
+ return operation
+
+
+class Marshallable(object):
+
+ def marshall(self):
+ marshalled = {}
+ for name in self.__slots__:
+ if name.startswith('_'):
+ continue
+ marshalled[name] = getattr(self, name)
+ return marshalled
From fafb211fd0ba9136189ec5626a159f74aebafc2d Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:48:16 -0700
Subject: [PATCH 16/20] Move Profile to its final spot
---
pylxd/client.py | 29 +----------------------------
pylxd/profiles.py | 31 ++++++++++++++++++++++++++++++-
2 files changed, 31 insertions(+), 29 deletions(-)
diff --git a/pylxd/client.py b/pylxd/client.py
index f9e3553..d54ae29 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -20,6 +20,7 @@
from pylxd import mixin
from pylxd.container import Container
+from pylxd.profiles import Profile
requests_unixsocket.monkeypatch()
@@ -204,34 +205,6 @@ def get(self, operation_id):
return Operation(_client=self._client, **response.json()['metadata'])
-class Profile(mixin.Marshallable):
-
- __slots__ = [
- '_client',
- 'config', 'devices', 'name'
- ]
-
- def __init__(self, **kwargs):
- super(Profile, self).__init__()
- for key, value in kwargs.iteritems():
- setattr(self, key, value)
-
- def update(self):
- marshalled = self.marshall()
- # The name property cannot be updated.
- del marshalled['name']
-
- self._client.api.profiles[self.name].put(json=marshalled)
-
- def rename(self, new):
- raise NotImplementedError('LXD does not currently support renaming profiles')
- self._client.api.profiles[self.name].post(json={'name': new})
- self.name = new
-
- def delete(self):
- self._client.api.profiles[self.name].delete()
-
-
class Image(mixin.Waitable, mixin.Marshallable):
__slots__ = [
diff --git a/pylxd/profiles.py b/pylxd/profiles.py
index f4fbc02..6dcff2e 100644
--- a/pylxd/profiles.py
+++ b/pylxd/profiles.py
@@ -11,10 +11,11 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
-
+# XXX: rockstar (15 Feb 2016) - This module should be renamed to 'profile'.
import json
from pylxd import base
+from pylxd import mixin
class LXDProfile(base.LXDBase):
@@ -56,3 +57,31 @@ def profile_delete(self, profile):
'''Delete the LXD profile'''
return self.connection.get_status('DELETE', '/1.0/profiles/%s'
% profile)
+
+
+class Profile(mixin.Marshallable):
+
+ __slots__ = [
+ '_client',
+ 'config', 'devices', 'name'
+ ]
+
+ def __init__(self, **kwargs):
+ super(Profile, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+ def update(self):
+ marshalled = self.marshall()
+ # The name property cannot be updated.
+ del marshalled['name']
+
+ self._client.api.profiles[self.name].put(json=marshalled)
+
+ def rename(self, new):
+ raise NotImplementedError('LXD does not currently support renaming profiles')
+ self._client.api.profiles[self.name].post(json={'name': new})
+ self.name = new
+
+ def delete(self):
+ self._client.api.profiles[self.name].delete()
From 9ed5d5d856e45d0a42e3ec7e5c930f0b7c24934f Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:49:50 -0700
Subject: [PATCH 17/20] Move Image to its final spot.
---
pylxd/client.py | 25 +------------------------
pylxd/image.py | 25 +++++++++++++++++++++++++
2 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/pylxd/client.py b/pylxd/client.py
index d54ae29..a51e7b3 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -20,6 +20,7 @@
from pylxd import mixin
from pylxd.container import Container
+from pylxd.image import Image
from pylxd.profiles import Profile
requests_unixsocket.monkeypatch()
@@ -205,30 +206,6 @@ def get(self, operation_id):
return Operation(_client=self._client, **response.json()['metadata'])
-class Image(mixin.Waitable, mixin.Marshallable):
-
- __slots__ = [
- '_client',
- 'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
- 'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
- ]
-
- def __init__(self, **kwargs):
- super(Image, self).__init__()
- for key, value in kwargs.iteritems():
- setattr(self, key, value)
-
- def update(self):
- self._client.api.images[self.fingerprint].put(
- json=self.marshall())
-
- def delete(self, wait=False):
- response = self._client.api.images[self.fingerprint].delete()
-
- if wait:
- self.wait_for_operation(response.json()['operation'])
-
-
class Operation(object):
__slots__ = [
diff --git a/pylxd/image.py b/pylxd/image.py
index 007098f..f49c58d 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -19,6 +19,7 @@
from pylxd import base
from pylxd import connection
from pylxd import exceptions
+from pylxd import mixin
image_architecture = {
0: 'Unknown',
@@ -241,3 +242,27 @@ def alias_create(self, data):
def alias_delete(self, alias):
return self.connection.get_status('DELETE', '/1.0/images/aliases/%s'
% alias)
+
+
+class Image(mixin.Waitable, mixin.Marshallable):
+
+ __slots__ = [
+ '_client',
+ 'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
+ 'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
+ ]
+
+ def __init__(self, **kwargs):
+ super(Image, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+ def update(self):
+ self._client.api.images[self.fingerprint].put(
+ json=self.marshall())
+
+ def delete(self, wait=False):
+ response = self._client.api.images[self.fingerprint].delete()
+
+ if wait:
+ self.wait_for_operation(response.json()['operation'])
From cf0904ff6cad99fde1995ee95e50df15fc942d4d Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:54:50 -0700
Subject: [PATCH 18/20] Move Operation to its final place
---
pylxd/client.py | 17 +----------------
pylxd/operation.py | 16 ++++++++++++++++
2 files changed, 17 insertions(+), 16 deletions(-)
diff --git a/pylxd/client.py b/pylxd/client.py
index a51e7b3..f93a36e 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -21,6 +21,7 @@
from pylxd import mixin
from pylxd.container import Container
from pylxd.image import Image
+from pylxd.operation import Operation
from pylxd.profiles import Profile
requests_unixsocket.monkeypatch()
@@ -204,19 +205,3 @@ def get(self, operation_id):
response = self._client.api.operations[operation_id].get()
return Operation(_client=self._client, **response.json()['metadata'])
-
-
-class Operation(object):
-
- __slots__ = [
- '_client',
- 'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
- 'resources', 'status', 'status_code', 'updated_at']
-
- def __init__(self, **kwargs):
- super(Operation, self).__init__()
- for key, value in kwargs.iteritems():
- setattr(self, key, value)
-
- def wait(self):
- self._client.api.operations[self.id].wait.get()
diff --git a/pylxd/operation.py b/pylxd/operation.py
index 879be65..d0282f2 100644
--- a/pylxd/operation.py
+++ b/pylxd/operation.py
@@ -72,3 +72,19 @@ def operation_stream(self, operation, operation_secret):
def operation_delete(self, operation):
return self.connection.get_status('DELETE', operation)
+
+
+class Operation(object):
+
+ __slots__ = [
+ '_client',
+ 'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
+ 'resources', 'status', 'status_code', 'updated_at']
+
+ def __init__(self, **kwargs):
+ super(Operation, self).__init__()
+ for key, value in kwargs.iteritems():
+ setattr(self, key, value)
+
+ def wait(self):
+ self._client.api.operations[self.id].wait.get()
From 1e06c43b4e07ca3e242ec6c6126bfd4d18f33994 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 18:18:33 -0700
Subject: [PATCH 19/20] Cleanup the convenience/wrapper classes in Client
---
pylxd/client.py | 155 +++++++++++++----------------------------------------
pylxd/container.py | 28 ++++++++++
pylxd/image.py | 35 ++++++++++++
pylxd/operation.py | 12 +++++
pylxd/profiles.py | 27 ++++++++++
5 files changed, 138 insertions(+), 119 deletions(-)
diff --git a/pylxd/client.py b/pylxd/client.py
index f93a36e..b2d5fc3 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -11,14 +11,13 @@
# 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 hashlib
+import functools
import os
import urllib
import requests
import requests_unixsocket
-from pylxd import mixin
from pylxd.container import Container
from pylxd.image import Image
from pylxd.operation import Operation
@@ -74,7 +73,37 @@ def delete(self, *args, **kwargs):
class Client(object):
- """A LXD client."""
+ """A LXD client.
+
+ This client wraps all the functionality required to interact with
+ LXD, and is meant to be the sole entry point.
+ """
+
+ class Containers(object):
+ """A convenience wrapper for Container."""
+ def __init__(self, client):
+ self.get = functools.partial(Container.get, client)
+ self.all = functools.partial(Container.all, client)
+ self.create = functools.partial(Container.create, client)
+
+ class Images(object):
+ """A convenience wrapper for Image."""
+ def __init__(self, client):
+ self.get = functools.partial(Image.get, client)
+ self.all = functools.partial(Image.all, client)
+ self.create = functools.partial(Image.create, client)
+
+ class Operations(object):
+ """A convenience wrapper for Operation."""
+ def __init__(self, client):
+ self.get = functools.partial(Operation.get, client)
+
+ class Profiles(object):
+ """A convenience wrapper for Profile."""
+ def __init__(self, client):
+ self.get = functools.partial(Profile.get, client)
+ self.all = functools.partial(Profile.all, client)
+ self.create = functools.partial(Profile.create, client)
def __init__(self, endpoint=None, version='1.0'):
if endpoint is not None:
@@ -89,119 +118,7 @@ def __init__(self, endpoint=None, version='1.0'):
urllib.quote(path, safe='')))
self.api = self.api[version]
- self.containers = _Containers(self)
- self.images = _Images(self)
- self.profiles = _Profiles(self)
- self.operations = _Operations(self)
-
-
-class _Containers(mixin.Waitable):
- """A wrapper for working with containers."""
-
- def __init__(self, client):
- self._client = client
-
- def get(self, name):
- response = self._client.api.containers[name].get()
-
- if response.status_code == 404:
- raise NameError('No container named "{}"'.format(name))
- container = Container(_client=self._client, **response.json()['metadata'])
- return container
-
- def all(self):
- response = self._client.api.containers.get()
-
- containers = []
- for url in response.json()['metadata']:
- name = url.split('/')[-1]
- containers.append(Container(_client=self._client, name=name))
- return containers
-
- def create(self, config, wait=False):
- response = self._client.api.containers.post(json=config)
-
- if wait:
- operation_id = response.json()['operation'].split('/')[-1]
- self.wait_for_operation(operation_id)
- return Container(name=config['name'])
-
-
-class _Images(mixin.Waitable):
- """A wrapper for working with images."""
-
- def __init__(self, client):
- self._client = client
-
- def get(self, fingerprint):
- response = self._client.api.images[fingerprint].get()
-
- if response.status_code == 404:
- raise NameError('No image with fingerprint "{}"'.format(fingerprint))
- image = Image(_client=self._client, **response.json()['metadata'])
- return image
-
- def all(self):
- response = self._client.api.images.get()
-
- images = []
- for url in response.json()['metadata']:
- fingerprint = url.split('/')[-1]
- images.append(Image(_client=self._client, fingerprint=fingerprint))
- return images
-
- def create(self, image_data, public=False, wait=False):
- fingerprint = hashlib.sha256(image_data).hexdigest()
-
- headers = {}
- if public:
- headers['X-LXD-Public'] = '1'
- response = self._client.api.images.post(
- data=image_data, headers=headers)
-
- if wait:
- self.wait_for_operation(response.json()['operation'])
- return self.get(fingerprint)
-
-
-class _Profiles(mixin.Waitable):
- """A wrapper for working with profiles."""
-
- def __init__(self, client):
- self._client = client
-
- def get(self, name):
- response = self._client.api.profiles[name].get()
-
- if response.status_code == 404:
- raise NameError('No profile with name "{}"'.format(name))
- return Profile(_client=self._client, **response.json()['metadata'])
-
- def all(self):
- response = self._client.api.profiles.get()
-
- profiles = []
- for url in response.json()['metadata']:
- name = url.split('/')[-1]
- profiles.append(Profile(_client=self._client, name=name))
- return profiles
-
- def create(self, name, config):
- self._client.api.profiles.post(json={
- 'name': name,
- 'config': config
- })
-
- return self.get(name)
-
-
-class _Operations(mixin.Waitable):
- """A wrapper for working with operations."""
-
- def __init__(self, client):
- self._client = client
-
- def get(self, operation_id):
- response = self._client.api.operations[operation_id].get()
-
- return Operation(_client=self._client, **response.json()['metadata'])
+ self.containers = self.Containers(self)
+ self.images = self.Images(self)
+ self.operations = self.Operations(self)
+ self.profiles = self.Profiles(self)
diff --git a/pylxd/container.py b/pylxd/container.py
index 6e44072..407e357 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -17,6 +17,7 @@
from pylxd import base
from pylxd import exceptions
from pylxd import mixin
+from pylxd.operation import Operation
class LXDContainer(base.LXDBase):
@@ -215,6 +216,33 @@ class Container(mixin.Waitable, mixin.Marshallable):
'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
]
+ @classmethod
+ def get(cls, client, name):
+ response = client.api.containers[name].get()
+
+ if response.status_code == 404:
+ raise NameError('No container named "{}"'.format(name))
+ container = cls(_client=client, **response.json()['metadata'])
+ return container
+
+ @classmethod
+ def all(cls, client):
+ response = client.api.containers.get()
+
+ containers = []
+ for url in response.json()['metadata']:
+ name = url.split('/')[-1]
+ containers.append(cls(_client=client, name=name))
+ return containers
+
+ @classmethod
+ def create(cls, client, config, wait=False):
+ response = client.api.containers.post(json=config)
+
+ if wait:
+ Operation.wait_for_operation(client, response.json()['operation'])
+ return cls(name=config['name'])
+
def __init__(self, **kwargs):
super(Container, self).__init__()
for key, value in kwargs.iteritems():
diff --git a/pylxd/image.py b/pylxd/image.py
index f49c58d..7cdbc72 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -13,6 +13,7 @@
# under the License.
from __future__ import print_function
import datetime
+import hashlib
import json
from six.moves import urllib
@@ -20,6 +21,7 @@
from pylxd import connection
from pylxd import exceptions
from pylxd import mixin
+from pylxd.operation import Operation
image_architecture = {
0: 'Unknown',
@@ -252,6 +254,39 @@ class Image(mixin.Waitable, mixin.Marshallable):
'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
]
+ @classmethod
+ def get(cls, client, fingerprint):
+ response = client.api.images[fingerprint].get()
+
+ if response.status_code == 404:
+ raise NameError('No image with fingerprint "{}"'.format(fingerprint))
+ image = Image(_client=client, **response.json()['metadata'])
+ return image
+
+ @classmethod
+ def all(cls, client):
+ response = client.api.images.get()
+
+ images = []
+ for url in response.json()['metadata']:
+ fingerprint = url.split('/')[-1]
+ images.append(Image(_client=client, fingerprint=fingerprint))
+ return images
+
+ @classmethod
+ def create(cls, client, image_data, public=False, wait=False):
+ fingerprint = hashlib.sha256(image_data).hexdigest()
+
+ headers = {}
+ if public:
+ headers['X-LXD-Public'] = '1'
+ response = client.api.images.post(
+ data=image_data, headers=headers)
+
+ if wait:
+ Operation.wait_for_operation(client, response.json()['operation'])
+ return cls.get(client, fingerprint)
+
def __init__(self, **kwargs):
super(Image, self).__init__()
for key, value in kwargs.iteritems():
diff --git a/pylxd/operation.py b/pylxd/operation.py
index d0282f2..93ab00e 100644
--- a/pylxd/operation.py
+++ b/pylxd/operation.py
@@ -81,6 +81,18 @@ class Operation(object):
'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
'resources', 'status', 'status_code', 'updated_at']
+ @classmethod
+ def wait_for_operation(cls, client, operation_id):
+ if operation_id.startswith('/'):
+ operation_id = operation_id.split('/')[-1]
+ operation = cls.get(client, operation_id)
+ operation.wait()
+
+ @classmethod
+ def get(cls, client, operation_id):
+ response = client.api.operations[operation_id].get()
+ return cls(_client=client, **response.json()['metadata'])
+
def __init__(self, **kwargs):
super(Operation, self).__init__()
for key, value in kwargs.iteritems():
diff --git a/pylxd/profiles.py b/pylxd/profiles.py
index 6dcff2e..2d4ec68 100644
--- a/pylxd/profiles.py
+++ b/pylxd/profiles.py
@@ -66,6 +66,33 @@ class Profile(mixin.Marshallable):
'config', 'devices', 'name'
]
+ @classmethod
+ def get(cls, client, name):
+ response = client.api.profiles[name].get()
+
+ if response.status_code == 404:
+ raise NameError('No profile with name "{}"'.format(name))
+ return cls(_client=client, **response.json()['metadata'])
+
+ @classmethod
+ def all(cls, client):
+ response = client.api.profiles.get()
+
+ profiles = []
+ for url in response.json()['metadata']:
+ name = url.split('/')[-1]
+ profiles.append(cls(_client=client, name=name))
+ return profiles
+
+ @classmethod
+ def create(cls, client, name, config):
+ client.api.profiles.post(json={
+ 'name': name,
+ 'config': config
+ })
+
+ return cls.get(client, name)
+
def __init__(self, **kwargs):
super(Profile, self).__init__()
for key, value in kwargs.iteritems():
From 32006f256354747915ae9af0101cde130b250598 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 18:28:21 -0700
Subject: [PATCH 20/20] Fixed the APINode tests
---
pylxd/tests/test_api.py | 90 ----------------------------------------------
pylxd/tests/test_client.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 90 insertions(+), 90 deletions(-)
delete mode 100644 pylxd/tests/test_api.py
create mode 100644 pylxd/tests/test_client.py
diff --git a/pylxd/tests/test_api.py b/pylxd/tests/test_api.py
deleted file mode 100644
index 60fb7f8..0000000
--- a/pylxd/tests/test_api.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.api."""
-import unittest
-
-import mock
-
-from pylxd import api
-
-
-class Test_APINode(unittest.TestCase):
- """Tests for pylxd.api._APINode."""
- ROOT = 'http://lxd/api'
-
- def test_getattr(self):
- """`__getattr__` returns a nested node."""
- lxd = api._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 = api._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 = api._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 = api._APINode(self.ROOT) # NOQA
-
- self.assertEqual(
- '{}/fake/0'.format(self.ROOT),
- lxd.fake[0]._api_endpoint)
-
- @mock.patch('pylxd.api.requests.get')
- def test_get(self, _get):
- """`get` will make a request to the smart url."""
- lxd = api._APINode(self.ROOT)
-
- lxd.fake.get()
-
- _get.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
- @mock.patch('pylxd.api.requests.post')
- def test_post(self, _post):
- """`post` will POST to the smart url."""
- lxd = api._APINode(self.ROOT)
-
- lxd.fake.post()
-
- _post.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
- @mock.patch('pylxd.api.requests.put')
- def test_put(self, _put):
- """`put` will PUT to the smart url."""
- lxd = api._APINode(self.ROOT)
-
- lxd.fake.put()
-
- _put.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
- @mock.patch('pylxd.api.requests.delete')
- def test_delete(self, _delete):
- """`delete` will DELETE to the smart url."""
- lxd = api._APINode(self.ROOT)
-
- lxd.fake.delete()
-
- _delete.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
new file mode 100644
index 0000000..821c59d
--- /dev/null
+++ b/pylxd/tests/test_client.py
@@ -0,0 +1,90 @@
+# 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'))
More information about the lxc-devel
mailing list