[lxc-devel] [pylxd/master] Add support for the instances and virtual-machines endpoints.
ltrager on Github
lxc-bot at linuxcontainers.org
Wed Feb 19 08:36:23 UTC 2020
A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 302 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20200219/a7432af2/attachment-0001.bin>
-------------- next part --------------
From 256cff77fc466c6bc5451a07a3910e03b07089b6 Mon Sep 17 00:00:00 2001
From: Lee Trager <lee at trager.us>
Date: Wed, 19 Feb 2020 08:29:07 +0000
Subject: [PATCH] Add support for the instances and virtual-machines endpoints.
---
pylxd/client.py | 22 +-
pylxd/managers.py | 8 +
pylxd/models/__init__.py | 4 +-
pylxd/models/container.py | 791 +----------------
pylxd/models/instance.py | 803 ++++++++++++++++++
pylxd/models/storage_pool.py | 4 +
pylxd/models/virtual_machine.py | 19 +
pylxd/tests/mock_lxd.py | 94 +-
.../{test_container.py => test_instance.py} | 404 ++++-----
pylxd/tests/models/test_storage.py | 2 +-
10 files changed, 1108 insertions(+), 1043 deletions(-)
create mode 100644 pylxd/models/instance.py
create mode 100644 pylxd/models/virtual_machine.py
rename pylxd/tests/models/{test_container.py => test_instance.py} (59%)
diff --git a/pylxd/client.py b/pylxd/client.py
index aa5118ca..ee9cc802 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -80,9 +80,9 @@ def __getattr__(self, name):
:returns: new _APINode with /<name> on the end
:rtype: _APINode
"""
- # Special case for storage_pools which needs to become 'storage-pools'
- if name == 'storage_pools':
- name = 'storage-pools'
+ # '-' can't be used in variable names
+ if name in ('storage_pools', 'virtual_machines'):
+ name = name.replace('_', '-')
return self.__class__('{}/{}'.format(self._api_endpoint, name),
cert=self.session.cert,
verify=self.session.verify,
@@ -222,11 +222,21 @@ class Client(object):
This client wraps all the functionality required to interact with
LXD, and is meant to be the sole entry point.
+ .. attribute:: instances
+
+ Instance of :class:`Client.Instances
+ <pylxd.client.Client.Instances>`:
+
.. attribute:: containers
Instance of :class:`Client.Containers
<pylxd.client.Client.Containers>`:
+ .. attribute:: virtual_machines
+
+ Instance of :class:`Client.VirtualMachines
+ <pylxd.client.Client.VirtualMachines>`:
+
.. attribute:: images
Instance of :class:`Client.Images <pylxd.client.Client.Images>`.
@@ -253,8 +263,8 @@ class Client(object):
>>> response = api.get()
# Check status code and response
>>> print response.status_code, response.json()
- # /containers/test/
- >>> print api.containers['test'].get().json()
+ # /instances/test/
+ >>> print api.instances['test'].get().json()
"""
@@ -316,7 +326,9 @@ def __init__(
self.cluster = managers.ClusterManager(self)
self.certificates = managers.CertificateManager(self)
+ self.instances = managers.InstanceManager(self)
self.containers = managers.ContainerManager(self)
+ self.virtual_machines = managers.VirtualMachineManager(self)
self.images = managers.ImageManager(self)
self.networks = managers.NetworkManager(self)
self.operations = managers.OperationManager(self)
diff --git a/pylxd/managers.py b/pylxd/managers.py
index 1b6cd478..6369e50c 100644
--- a/pylxd/managers.py
+++ b/pylxd/managers.py
@@ -31,10 +31,18 @@ class CertificateManager(BaseManager):
manager_for = 'pylxd.models.Certificate'
+class InstanceManager(BaseManager):
+ manager_for = 'pylxd.models.Instance'
+
+
class ContainerManager(BaseManager):
manager_for = 'pylxd.models.Container'
+class VirtualMachineManager(BaseManager):
+ manager_for = 'pylxd.models.VirtualMachine'
+
+
class ImageManager(BaseManager):
manager_for = 'pylxd.models.Image'
diff --git a/pylxd/models/__init__.py b/pylxd/models/__init__.py
index c71504cc..eb2ca532 100644
--- a/pylxd/models/__init__.py
+++ b/pylxd/models/__init__.py
@@ -1,6 +1,8 @@
from pylxd.models.cluster import (Cluster, ClusterMember) # NOQA
from pylxd.models.certificate import Certificate # NOQA
-from pylxd.models.container import Container, Snapshot # NOQA
+from pylxd.models.instance import Instance, Snapshot # NOQA
+from pylxd.models.container import Container # NOQA
+from pylxd.models.virtual_machine import VirtualMachine # NOQA
from pylxd.models.image import Image # NOQA
from pylxd.models.network import Network # NOQA
from pylxd.models.operation import Operation # NOQA
diff --git a/pylxd/models/container.py b/pylxd/models/container.py
index 47a72ded..2bfbf205 100644
--- a/pylxd/models/container.py
+++ b/pylxd/models/container.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2016 Canonical Ltd
+# Copyright (c) 2020 Canonical Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
@@ -11,792 +11,9 @@
# 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 collections
-import os
-import stat
-import time
+from pylxd.models import Instance
-import six
-from six.moves.urllib import parse
-try:
- from ws4py.client import WebSocketBaseClient
- from ws4py.manager import WebSocketManager
- from ws4py.messaging import BinaryMessage
- _ws4py_installed = True
-except ImportError: # pragma: no cover
- WebSocketBaseClient = object
- _ws4py_installed = False
-from pylxd import managers
-from pylxd.exceptions import LXDAPIException
-from pylxd.models import _model as model
-from pylxd.models.operation import Operation
+class Container(Instance):
-if six.PY2:
- # Python2.7 doesn't have this natively
- from pylxd.exceptions import NotADirectoryError
-
-
-class ContainerState(object):
- """A simple object for representing container state."""
-
- def __init__(self, **kwargs):
- for key, value in six.iteritems(kwargs):
- setattr(self, key, value)
-
-
-_ContainerExecuteResult = collections.namedtuple(
- 'ContainerExecuteResult',
- ['exit_code', 'stdout', 'stderr'])
-
-
-class Container(model.Model):
- """An LXD Container.
-
- This class is not intended to be used directly, but rather to be used
- via `Client.containers.create`.
- """
-
- architecture = model.Attribute()
- config = model.Attribute()
- created_at = model.Attribute()
- devices = model.Attribute()
- ephemeral = model.Attribute()
- expanded_config = model.Attribute()
- expanded_devices = model.Attribute()
- name = model.Attribute(readonly=True)
- description = model.Attribute()
- profiles = model.Attribute()
- status = model.Attribute(readonly=True)
- last_used_at = model.Attribute(readonly=True)
- location = model.Attribute(readonly=True)
-
- status_code = model.Attribute(readonly=True)
- stateful = model.Attribute(readonly=True)
-
- snapshots = model.Manager()
- files = model.Manager()
-
- @property
- def api(self):
- return self.client.api.containers[self.name]
-
- class FilesManager(object):
- """A pseudo-manager for namespacing file operations."""
-
- def __init__(self, client, container):
- self._client = client
- self._container = container
-
- def put(self, filepath, data, mode=None, uid=None, gid=None):
- """Push a file to the container.
-
- This pushes a single file to the containers file system named by
- the `filepath`.
-
- :param filepath: The path in the container to to store the data in.
- :type filepath: str
- :param data: The data to store in the file.
- :type data: bytes or str
- :param mode: The unit mode to store the file with. The default of
- None stores the file with the current mask of 0700, which is
- the lxd default.
- :type mode: Union[oct, int, str]
- :param uid: The uid to use inside the container. Default of None
- results in 0 (root).
- :type uid: int
- :param gid: The gid to use inside the container. Default of None
- results in 0 (root).
- :type gid: int
- :raises: LXDAPIException if something goes wrong
- """
- headers = self._resolve_headers(mode=mode, uid=uid, gid=gid)
- response = (self._client.api.containers[self._container.name]
- .files.post(params={'path': filepath},
- data=data,
- headers=headers or None))
- if response.status_code == 200:
- return
- raise LXDAPIException(response)
-
- @staticmethod
- def _resolve_headers(headers=None, mode=None, uid=None, gid=None):
- if headers is None:
- headers = {}
- if mode is not None:
- if isinstance(mode, int):
- mode = format(mode, 'o')
- if not isinstance(mode, six.string_types):
- raise ValueError("'mode' parameter must be int or string")
- if not mode.startswith('0'):
- mode = '0{}'.format(mode)
- headers['X-LXD-mode'] = mode
- if uid is not None:
- headers['X-LXD-uid'] = str(uid)
- if gid is not None:
- headers['X-LXD-gid'] = str(gid)
- return headers
-
- def delete_available(self):
- """File deletion is an extension API and may not be available.
- https://github.com/lxc/lxd/blob/master/doc/api-extensions.md#file_delete
- """
- return self._client.has_api_extension('file_delete')
-
- def delete(self, filepath):
- self._client.assert_has_api_extension('file_delete')
- response = self._client.api.containers[
- self._container.name].files.delete(
- params={'path': filepath})
- if response.status_code != 200:
- raise LXDAPIException(response)
-
- def get(self, filepath):
- response = (self._client.api.containers[self._container.name]
- .files.get(params={'path': filepath}, is_api=False))
- return response.content
-
- def recursive_put(self, src, dst, mode=None, uid=None, gid=None):
- """Recursively push directory to the container.
-
- Recursively pushes directory to the containers
- named by the `dst`
-
- :param src: The source path of directory to copy.
- :type src: str
- :param dst: The destination path in the container
- of directory to copy
- :type dst: str
- :param mode: The unit mode to store the file with. The default of
- None stores the file with the current mask of 0700, which is
- the lxd default.
- :type mode: Union[oct, int, str]
- :param uid: The uid to use inside the container. Default of None
- results in 0 (root).
- :type uid: int
- :param gid: The gid to use inside the container. Default of None
- results in 0 (root).
- :type gid: int
- :raises: NotADirectoryError if src is not a directory
- :raises: LXDAPIException if an error occurs
- """
- norm_src = os.path.normpath(src)
- if not os.path.isdir(norm_src):
- raise NotADirectoryError(
- "'src' parameter must be a directory "
- )
-
- idx = len(norm_src)
- dst_items = set()
-
- for path, dirname, files in os.walk(norm_src):
- dst_path = os.path.normpath(
- os.path.join(dst, path[idx:].lstrip(os.path.sep)))
- # create directory or symlink (depending on what's there)
- if path not in dst_items:
- dst_items.add(path)
- headers = self._resolve_headers(mode=mode,
- uid=uid, gid=gid)
- # determine what the file is: a directory or a symlink
- fmode = os.stat(path).st_mode
- if stat.S_ISLNK(fmode):
- headers['X-LXD-type'] = 'symlink'
- else:
- headers['X-LXD-type'] = 'directory'
- (self._client.api.containers[self._container.name]
- .files.post(params={'path': dst_path},
- headers=headers))
-
- # copy files
- for f in files:
- src_file = os.path.join(path, f)
- with open(src_file, 'rb') as fp:
- filepath = os.path.join(dst_path, f)
- headers = self._resolve_headers(mode=mode,
- uid=uid,
- gid=gid)
- response = (
- self._client.api.containers[self._container.name]
- .files.post(params={'path': filepath},
- data=fp.read(),
- headers=headers or None))
- if response.status_code != 200:
- raise LXDAPIException(response)
-
- @classmethod
- def exists(cls, client, name):
- """Determine whether a container exists."""
- try:
- client.containers.get(name)
- return True
- except cls.NotFound:
- return False
-
- @classmethod
- def get(cls, client, name):
- """Get a container by name."""
- response = client.api.containers[name].get()
-
- container = cls(client, **response.json()['metadata'])
- return container
-
- @classmethod
- def all(cls, client):
- """Get all containers.
-
- Containers returned from this method will only have the name
- set, as that is the only property returned from LXD. If more
- information is needed, `Container.sync` is the method call
- that should be used.
- """
- response = client.api.containers.get()
-
- containers = []
- for url in response.json()['metadata']:
- name = url.split('/')[-1]
- containers.append(cls(client, name=name))
- return containers
-
- @classmethod
- def create(cls, client, config, wait=False, target=None):
- """Create a new container config.
-
- :param client: client instance
- :type client: Client
- :param config: The configuration for the new container.
- :type config: dict
- :param wait: Whether to wait for async operations to complete.
- :type wait: bool
- :param target: If in cluster mode, the target member.
- :type target: str
- :raises LXDAPIException: if something goes wrong.
- :returns: a container if successful
- :rtype: :class:`Container`
- """
- response = client.api.containers.post(json=config, target=target)
-
- if wait:
- client.operations.wait_for_operation(response.json()['operation'])
- return cls(client, name=config['name'])
-
- def __init__(self, *args, **kwargs):
- super(Container, self).__init__(*args, **kwargs)
-
- self.snapshots = managers.SnapshotManager(self.client, self)
- self.files = self.FilesManager(self.client, self)
-
- def rename(self, name, wait=False):
- """Rename a container."""
- response = self.api.post(json={'name': name})
-
- if wait:
- self.client.operations.wait_for_operation(
- response.json()['operation'])
- self.name = name
-
- def _set_state(self, state, timeout=30, force=True, wait=False):
- response = self.api.state.put(json={
- 'action': state,
- 'timeout': timeout,
- 'force': force
- })
- if wait:
- self.client.operations.wait_for_operation(
- response.json()['operation'])
- if 'status' in self.__dirty__:
- del self.__dirty__[self.__dirty__.index('status')]
- if self.ephemeral and state == 'stop':
- self.client = None
- else:
- self.sync()
-
- def state(self):
- response = self.api.state.get()
- state = ContainerState(**response.json()['metadata'])
- return state
-
- def start(self, timeout=30, force=True, wait=False):
- """Start the container."""
- return self._set_state('start',
- timeout=timeout,
- force=force,
- wait=wait)
-
- def stop(self, timeout=30, force=True, wait=False):
- """Stop the container."""
- return self._set_state('stop',
- timeout=timeout,
- force=force,
- wait=wait)
-
- def restart(self, timeout=30, force=True, wait=False):
- """Restart the container."""
- return self._set_state('restart',
- timeout=timeout,
- force=force,
- wait=wait)
-
- def freeze(self, timeout=30, force=True, wait=False):
- """Freeze the container."""
- return self._set_state('freeze',
- timeout=timeout,
- force=force,
- wait=wait)
-
- def unfreeze(self, timeout=30, force=True, wait=False):
- """Unfreeze the container."""
- return self._set_state('unfreeze',
- timeout=timeout,
- force=force,
- wait=wait)
-
- def execute(
- self, commands, environment=None, encoding=None, decode=True,
- stdin_payload=None, stdin_encoding="utf-8",
- stdout_handler=None, stderr_handler=None):
- """Execute a command on the container. stdout and stderr are buffered if
- no handler is given.
-
- :param commands: The command and arguments as a list of strings
- :type commands: [str]
- :param environment: The environment variables to pass with the command
- :type environment: {str: str}
- :param encoding: The encoding to use for stdout/stderr if the param
- decode is True. If encoding is None, then no override is
- performed and whatever the existing encoding from LXD is used.
- :type encoding: str
- :param decode: Whether to decode the stdout/stderr or just return the
- raw buffers.
- :type decode: bool
- :param stdin_payload: Payload to pass via stdin
- :type stdin_payload: Can be a file, string, bytearray, generator or
- ws4py Message object
- :param stdin_encoding: Encoding to pass text to stdin (default utf-8)
- :param stdout_handler: Callable than receive as first parameter each
- message received via stdout
- :type stdout_handler: Callable[[str], None]
- :param stderr_handler: Callable than receive as first parameter each
- message received via stderr
- :type stderr_handler: Callable[[str], None]
- :raises ValueError: if the ws4py library is not installed.
- :returns: A tuple of `(exit_code, stdout, stderr)`
- :rtype: _ContainerExecuteResult() namedtuple
- """
- if not _ws4py_installed:
- raise ValueError(
- 'This feature requires the optional ws4py library.')
- if isinstance(commands, six.string_types):
- raise TypeError("First argument must be a list.")
- if environment is None:
- environment = {}
-
- response = self.api['exec'].post(json={
- 'command': commands,
- 'environment': environment,
- 'wait-for-websocket': True,
- 'interactive': False,
- })
-
- fds = response.json()['metadata']['metadata']['fds']
- operation_id = \
- Operation.extract_operation_id(response.json()['operation'])
- parsed = parse.urlparse(
- self.client.api.operations[operation_id].websocket._api_endpoint)
-
- with managers.web_socket_manager(WebSocketManager()) as manager:
- stdin = _StdinWebsocket(
- self.client.websocket_url, payload=stdin_payload,
- encoding=stdin_encoding
- )
- stdin.resource = '{}?secret={}'.format(parsed.path, fds['0'])
- stdin.connect()
- stdout = _CommandWebsocketClient(
- manager, self.client.websocket_url,
- encoding=encoding, decode=decode, handler=stdout_handler)
- stdout.resource = '{}?secret={}'.format(parsed.path, fds['1'])
- stdout.connect()
- stderr = _CommandWebsocketClient(
- manager, self.client.websocket_url,
- encoding=encoding, decode=decode, handler=stderr_handler)
- stderr.resource = '{}?secret={}'.format(parsed.path, fds['2'])
- stderr.connect()
-
- manager.start()
-
- # watch for the end of the command:
- while True:
- operation = self.client.operations.get(operation_id)
- if 'return' in operation.metadata:
- break
- time.sleep(.5) # pragma: no cover
-
- try:
- stdin.close()
- except BrokenPipeError:
- pass
-
- stdout.finish_soon()
- stderr.finish_soon()
- manager.close_all()
-
- while not stdout.finished or not stderr.finished:
- time.sleep(.1) # progma: no cover
-
- manager.stop()
- manager.join()
-
- return _ContainerExecuteResult(
- operation.metadata['return'], stdout.data, stderr.data)
-
- def raw_interactive_execute(self, commands, environment=None):
- """Execute a command on the container interactively and returns
- urls to websockets. The urls contain a secret uuid, and can be accesses
- without further authentication. The caller has to open and manage
- the websockets themselves.
-
- :param commands: The command and arguments as a list of strings
- (most likely a shell)
- :type commands: [str]
- :param environment: The environment variables to pass with the command
- :type environment: {str: str}
- :returns: Two urls to an interactive websocket and a control socket
- :rtype: {'ws':str,'control':str}
- """
- if isinstance(commands, six.string_types):
- raise TypeError("First argument must be a list.")
-
- if environment is None:
- environment = {}
-
- response = self.api['exec'].post(json={
- 'command': commands,
- 'environment': environment,
- 'wait-for-websocket': True,
- 'interactive': True,
- })
-
- fds = response.json()['metadata']['metadata']['fds']
- operation_id = response.json()['operation']\
- .split('/')[-1].split('?')[0]
- parsed = parse.urlparse(
- self.client.api.operations[operation_id].websocket._api_endpoint)
-
- return {'ws': '{}?secret={}'.format(parsed.path, fds['0']),
- 'control': '{}?secret={}'.format(parsed.path, fds['control'])}
-
- def migrate(self, new_client, live=False, wait=False):
- """Migrate a container.
-
- Destination host information is contained in the client
- connection passed in.
-
- If the `live` param is True, then a live migration is attempted,
- otherwise a non live one is running.
-
- If the container is running for live migration, it either must be shut
- down first or criu must be installed on the source and destination
- machines and the `live` param should be True.
-
- :param new_client: the pylxd client connection to migrate the container
- to.
- :type new_client: :class:`pylxd.client.Client`
- :param live: whether to perform a live migration
- :type live: bool
- :param wait: if True, wait for the migration to complete
- :type wait: bool
- :raises: LXDAPIException if any of the API calls fail.
- :raises: ValueError if source of target are local connections
- :returns: the response from LXD of the new container (the target of the
- migration and not the operation if waited on.)
- :rtype: :class:`requests.Response`
- """
- if self.api.scheme in ('http+unix',):
- raise ValueError('Cannot migrate from a local client connection')
-
- if self.status_code == 103:
- try:
- res = new_client.containers.create(
- self.generate_migration_data(live), wait=wait)
- except LXDAPIException as e:
- if e.response.status_code == 103:
- self.delete()
- return new_client.containers.get(self.name)
- else:
- raise e
- else:
- res = new_client.containers.create(
- self.generate_migration_data(live), wait=wait)
- self.delete()
- return res
-
- def generate_migration_data(self, live=False):
- """Generate the migration data.
-
- This method can be used to handle migrations where the client
- connection uses the local unix socket. For more information on
- migration, see `Container.migrate`.
-
- :param live: Whether to include "live": "true" in the migration
- :type live: bool
- :raises: LXDAPIException if the request to migrate fails
- :returns: dictionary of migration data suitable to send to an new
- client to complete a migration.
- :rtype: Dict[str, ANY]
- """
- self.sync() # Make sure the object isn't stale
- _json = {'migration': True}
- if live:
- _json['live'] = True
- response = self.api.post(json=_json)
- operation = self.client.operations.get(response.json()['operation'])
- operation_url = self.client.api.operations[operation.id]._api_endpoint
- secrets = response.json()['metadata']['metadata']
- cert = self.client.host_info['environment']['certificate']
-
- return {
- 'name': self.name,
- 'architecture': self.architecture,
- 'config': self.config,
- 'devices': self.devices,
- 'epehemeral': self.ephemeral,
- 'default': self.profiles,
- 'source': {
- 'type': 'migration',
- 'operation': operation_url,
- 'mode': 'pull',
- 'certificate': cert,
- 'secrets': secrets,
- }
- }
-
- def publish(self, public=False, wait=False):
- """Publish a container as an image.
-
- The container must be stopped in order publish it as an image. This
- method does not enforce that constraint, so a LXDAPIException may be
- raised if this method is called on a running container.
-
- If wait=True, an Image is returned.
- """
- data = {
- 'public': public,
- 'source': {
- 'type': 'container',
- 'name': self.name,
- }
- }
-
- response = self.client.api.images.post(json=data)
- if wait:
- operation = self.client.operations.wait_for_operation(
- response.json()['operation'])
-
- return self.client.images.get(operation.metadata['fingerprint'])
-
- def restore_snapshot(self, snapshot_name, wait=False):
- """Restore a snapshot using its name.
-
- Attempts to restore a container using a snapshot previously made. The
- container should be stopped, but the method does not enforce this
- constraint, so an LXDAPIException may be raised if this method fails.
-
- :param snapshot_name: the name of the snapshot to restore from
- :type snapshot_name: str
- :param wait: wait until the operation is completed.
- :type wait: boolean
- :raises: LXDAPIException if the the operation fails.
- :returns: the original response from the restore operation (not the
- operation result)
- :rtype: :class:`requests.Response`
- """
- response = self.api.put(json={"restore": snapshot_name})
- if wait:
- self.client.operations.wait_for_operation(
- response.json()['operation'])
- return response
-
-
-class _CommandWebsocketClient(WebSocketBaseClient): # pragma: no cover
- """Handle a websocket for container.execute(...) and manage decoding of the
- returned values depending on 'decode' and 'encoding' parameters.
- """
-
- def __init__(self, manager, *args, **kwargs):
- self.manager = manager
- self.decode = kwargs.pop('decode', True)
- self.encoding = kwargs.pop('encoding', None)
- self.handler = kwargs.pop('handler', None)
- self.message_encoding = None
- self.finish_off = False
- self.finished = False
- self.last_message_empty = False
- self.buffer = []
- super(_CommandWebsocketClient, self).__init__(*args, **kwargs)
-
- def handshake_ok(self):
- self.manager.add(self)
- self.buffer = []
-
- def received_message(self, message):
- if message.data is None or len(message.data) == 0:
- self.last_message_empty = True
- if self.finish_off:
- self.finished = True
- return
- else:
- self.last_message_empty = False
- if message.encoding and self.message_encoding is None:
- self.message_encoding = message.encoding
- if self.handler:
- self.handler(self._maybe_decode(message.data))
- else:
- self.buffer.append(message.data)
- if self.finish_off and isinstance(message, BinaryMessage):
- self.finished = True
-
- def closed(self, code, reason=None):
- self.finished = True
-
- def finish_soon(self):
- self.finish_off = True
- if self.last_message_empty:
- self.finished = True
-
- def _maybe_decode(self, buffer):
- if self.decode and buffer is not None:
- if self.encoding:
- return buffer.decode(self.encoding)
- if self.message_encoding:
- return buffer.decode(self.message_encoding)
- # This is the backwards compatible "always decode to utf-8"
- return buffer.decode('utf-8')
- return buffer
-
- @property
- def data(self):
- buffer = b''.join(self.buffer)
- return self._maybe_decode(buffer)
-
-
-class _StdinWebsocket(WebSocketBaseClient): # pragma: no cover
- """A websocket client for handling stdin.
-
- Allow comunicate with container commands via stdin
- """
-
- def __init__(self, url, payload=None, **kwargs):
- self.encoding = kwargs.pop('encoding', None)
- self.payload = payload
- super(_StdinWebsocket, self).__init__(url, **kwargs)
-
- def _smart_encode(self, msg):
- if type(msg) == six.text_type and self.encoding:
- return msg.encode(self.encoding)
- return msg
-
- def handshake_ok(self):
- if self.payload:
- if hasattr(self.payload, "read"):
- self.send(
- (self._smart_encode(line) for line in self.payload),
- binary=True
- )
- else:
- self.send(self._smart_encode(self.payload), binary=True)
- self.send(b"", binary=False)
-
-
-class Snapshot(model.Model):
- """A container snapshot."""
-
- name = model.Attribute()
- created_at = model.Attribute()
- stateful = model.Attribute()
-
- container = model.Parent()
-
- @property
- def api(self):
- return self.client.api.containers[
- self.container.name].snapshots[self.name]
-
- @classmethod
- def get(cls, client, container, name):
- response = client.api.containers[
- container.name].snapshots[name].get()
-
- snapshot = cls(
- client, container=container,
- **response.json()['metadata'])
- # Snapshot names are namespaced in LXD, as
- # container-name/snapshot-name. We hide that implementation
- # detail.
- snapshot.name = snapshot.name.split('/')[-1]
- return snapshot
-
- @classmethod
- def all(cls, client, container):
- response = client.api.containers[container.name].snapshots.get()
-
- return [cls(
- client, name=snapshot.split('/')[-1],
- container=container)
- for snapshot in response.json()['metadata']]
-
- @classmethod
- def create(cls, client, container, name, stateful=False, wait=False):
- response = client.api.containers[container.name].snapshots.post(json={
- 'name': name, 'stateful': stateful})
-
- snapshot = cls(client, container=container, name=name)
- if wait:
- client.operations.wait_for_operation(response.json()['operation'])
- return snapshot
-
- def rename(self, new_name, wait=False):
- """Rename a snapshot."""
- response = self.api.post(json={'name': new_name})
- if wait:
- self.client.operations.wait_for_operation(
- response.json()['operation'])
- self.name = new_name
-
- def publish(self, public=False, wait=False):
- """Publish a snapshot as an image.
-
- If wait=True, an Image is returned.
-
- This functionality is currently broken in LXD. Please see
- https://github.com/lxc/lxd/issues/2201 - The implementation
- here is mostly a guess. Once that bug is fixed, we can verify
- that this works, or file a bug to fix it appropriately.
- """
- data = {
- 'public': public,
- 'source': {
- 'type': 'snapshot',
- 'name': '{}/{}'.format(self.container.name, self.name),
- }
- }
-
- response = self.client.api.images.post(json=data)
- if wait:
- operation = self.client.operations.wait_for_operation(
- response.json()['operation'])
- return self.client.images.get(operation.metadata['fingerprint'])
-
- def restore(self, wait=False):
- """Restore this snapshot.
-
- Attempts to restore a container using this snapshot. The container
- should be stopped, but the method does not enforce this constraint, so
- an LXDAPIException may be raised if this method fails.
-
- :param wait: wait until the operation is completed.
- :type wait: boolean
- :raises: LXDAPIException if the the operation fails.
- :returns: the original response from the restore operation (not the
- operation result)
- :rtype: :class:`requests.Response`
- """
- return self.container.restore_snapshot(self.name, wait)
+ _endpoint = 'containers'
diff --git a/pylxd/models/instance.py b/pylxd/models/instance.py
new file mode 100644
index 00000000..35621617
--- /dev/null
+++ b/pylxd/models/instance.py
@@ -0,0 +1,803 @@
+# Copyright (c) 2016-2020 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+import collections
+import os
+import stat
+import time
+
+import six
+from six.moves.urllib import parse
+try:
+ from ws4py.client import WebSocketBaseClient
+ from ws4py.manager import WebSocketManager
+ from ws4py.messaging import BinaryMessage
+ _ws4py_installed = True
+except ImportError: # pragma: no cover
+ WebSocketBaseClient = object
+ _ws4py_installed = False
+
+from pylxd import managers
+from pylxd.exceptions import LXDAPIException
+from pylxd.models import _model as model
+from pylxd.models.operation import Operation
+
+if six.PY2:
+ # Python2.7 doesn't have this natively
+ from pylxd.exceptions import NotADirectoryError
+
+
+class InstanceState(object):
+ """A simple object for representing instance state."""
+
+ def __init__(self, **kwargs):
+ for key, value in six.iteritems(kwargs):
+ setattr(self, key, value)
+
+
+_InstanceExecuteResult = collections.namedtuple(
+ 'InstanceExecuteResult',
+ ['exit_code', 'stdout', 'stderr'])
+
+
+class Instance(model.Model):
+ """An LXD Instance.
+
+ This class is not intended to be used directly, but rather to be used
+ via `Client.instance.create`.
+ """
+
+ architecture = model.Attribute()
+ config = model.Attribute()
+ created_at = model.Attribute()
+ devices = model.Attribute()
+ ephemeral = model.Attribute()
+ expanded_config = model.Attribute()
+ expanded_devices = model.Attribute()
+ name = model.Attribute(readonly=True)
+ description = model.Attribute()
+ profiles = model.Attribute()
+ status = model.Attribute(readonly=True)
+ last_used_at = model.Attribute(readonly=True)
+ location = model.Attribute(readonly=True)
+ type = model.Attribute(readonly=True)
+
+ status_code = model.Attribute(readonly=True)
+ stateful = model.Attribute(readonly=True)
+
+ snapshots = model.Manager()
+ files = model.Manager()
+
+ _endpoint = 'instances'
+
+ @property
+ def api(self):
+ return getattr(self.client.api, self._endpoint)[self.name]
+
+ class FilesManager(object):
+ """A pseudo-manager for namespacing file operations."""
+
+ def __init__(self, instance):
+ self._instance = instance
+ self._endpoint = getattr(
+ instance.client.api, instance._endpoint)[instance.name].files
+
+ def put(self, filepath, data, mode=None, uid=None, gid=None):
+ """Push a file to the instance.
+
+ This pushes a single file to the instances file system named by
+ the `filepath`.
+
+ :param filepath: The path in the instance to to store the data in.
+ :type filepath: str
+ :param data: The data to store in the file.
+ :type data: bytes or str
+ :param mode: The unit mode to store the file with. The default of
+ None stores the file with the current mask of 0700, which is
+ the lxd default.
+ :type mode: Union[oct, int, str]
+ :param uid: The uid to use inside the instance. Default of None
+ results in 0 (root).
+ :type uid: int
+ :param gid: The gid to use inside the instance. Default of None
+ results in 0 (root).
+ :type gid: int
+ :raises: LXDAPIException if something goes wrong
+ """
+ headers = self._resolve_headers(mode=mode, uid=uid, gid=gid)
+ response = self._endpoint.post(
+ params={'path': filepath},
+ data=data,
+ headers=headers or None)
+ if response.status_code == 200:
+ return
+ raise LXDAPIException(response)
+
+ @staticmethod
+ def _resolve_headers(headers=None, mode=None, uid=None, gid=None):
+ if headers is None:
+ headers = {}
+ if mode is not None:
+ if isinstance(mode, int):
+ mode = format(mode, 'o')
+ if not isinstance(mode, six.string_types):
+ raise ValueError("'mode' parameter must be int or string")
+ if not mode.startswith('0'):
+ mode = '0{}'.format(mode)
+ headers['X-LXD-mode'] = mode
+ if uid is not None:
+ headers['X-LXD-uid'] = str(uid)
+ if gid is not None:
+ headers['X-LXD-gid'] = str(gid)
+ return headers
+
+ def delete_available(self):
+ """File deletion is an extension API and may not be available.
+ https://github.com/lxc/lxd/blob/master/doc/api-extensions.md#file_delete
+ """
+ return self._instance.client.has_api_extension('file_delete')
+
+ def delete(self, filepath):
+ self._instance.client.assert_has_api_extension('file_delete')
+ response = self._endpoint.delete(params={'path': filepath})
+ if response.status_code != 200:
+ raise LXDAPIException(response)
+
+ def get(self, filepath):
+ response = self._endpoint.get(
+ params={'path': filepath}, is_api=False)
+ return response.content
+
+ def recursive_put(self, src, dst, mode=None, uid=None, gid=None):
+ """Recursively push directory to the instance.
+
+ Recursively pushes directory to the instances
+ named by the `dst`
+
+ :param src: The source path of directory to copy.
+ :type src: str
+ :param dst: The destination path in the instance
+ of directory to copy
+ :type dst: str
+ :param mode: The unit mode to store the file with. The default of
+ None stores the file with the current mask of 0700, which is
+ the lxd default.
+ :type mode: Union[oct, int, str]
+ :param uid: The uid to use inside the instance. Default of None
+ results in 0 (root).
+ :type uid: int
+ :param gid: The gid to use inside the instance. Default of None
+ results in 0 (root).
+ :type gid: int
+ :raises: NotADirectoryError if src is not a directory
+ :raises: LXDAPIException if an error occurs
+ """
+ norm_src = os.path.normpath(src)
+ if not os.path.isdir(norm_src):
+ raise NotADirectoryError(
+ "'src' parameter must be a directory "
+ )
+
+ idx = len(norm_src)
+ dst_items = set()
+
+ for path, dirname, files in os.walk(norm_src):
+ dst_path = os.path.normpath(
+ os.path.join(dst, path[idx:].lstrip(os.path.sep)))
+ # create directory or symlink (depending on what's there)
+ if path not in dst_items:
+ dst_items.add(path)
+ headers = self._resolve_headers(mode=mode,
+ uid=uid, gid=gid)
+ # determine what the file is: a directory or a symlink
+ fmode = os.stat(path).st_mode
+ if stat.S_ISLNK(fmode):
+ headers['X-LXD-type'] = 'symlink'
+ else:
+ headers['X-LXD-type'] = 'directory'
+ self._endpoint.post(
+ params={'path': dst_path},
+ headers=headers)
+
+ # copy files
+ for f in files:
+ src_file = os.path.join(path, f)
+ with open(src_file, 'rb') as fp:
+ filepath = os.path.join(dst_path, f)
+ headers = self._resolve_headers(mode=mode,
+ uid=uid,
+ gid=gid)
+ response = self._endpoint.post(
+ params={'path': filepath},
+ data=fp.read(),
+ headers=headers or None)
+ if response.status_code != 200:
+ raise LXDAPIException(response)
+
+ @classmethod
+ def exists(cls, client, name):
+ """Determine whether a instance exists."""
+ try:
+ getattr(client, cls._endpoint).get(name)
+ return True
+ except cls.NotFound:
+ return False
+
+ @classmethod
+ def get(cls, client, name):
+ """Get a instance by name."""
+ response = getattr(client.api, cls._endpoint)[name].get()
+
+ return cls(client, **response.json()['metadata'])
+
+ @classmethod
+ def all(cls, client):
+ """Get all instances.
+
+ Instances returned from this method will only have the name
+ set, as that is the only property returned from LXD. If more
+ information is needed, `Instance.sync` is the method call
+ that should be used.
+ """
+ response = getattr(client.api, cls._endpoint).get()
+
+ instances = []
+ for url in response.json()['metadata']:
+ name = url.split('/')[-1]
+ instances.append(cls(client, name=name))
+ return instances
+
+ @classmethod
+ def create(cls, client, config, wait=False, target=None):
+ """Create a new instance config.
+
+ :param client: client instance
+ :type client: Client
+ :param config: The configuration for the new instance.
+ :type config: dict
+ :param wait: Whether to wait for async operations to complete.
+ :type wait: bool
+ :param target: If in cluster mode, the target member.
+ :type target: str
+ :raises LXDAPIException: if something goes wrong.
+ :returns: an instance if successful
+ :rtype: :class:`Instance`
+ """
+ response = getattr(client.api, cls._endpoint).post(
+ json=config, target=target)
+
+ if wait:
+ client.operations.wait_for_operation(response.json()['operation'])
+ return cls(client, name=config['name'])
+
+ def __init__(self, *args, **kwargs):
+ super(Instance, self).__init__(*args, **kwargs)
+
+ self.snapshots = managers.SnapshotManager(self.client, self)
+ self.files = self.FilesManager(self)
+
+ def rename(self, name, wait=False):
+ """Rename an instance."""
+ response = self.api.post(json={'name': name})
+
+ if wait:
+ self.client.operations.wait_for_operation(
+ response.json()['operation'])
+ self.name = name
+
+ def _set_state(self, state, timeout=30, force=True, wait=False):
+ response = self.api.state.put(json={
+ 'action': state,
+ 'timeout': timeout,
+ 'force': force
+ })
+ if wait:
+ self.client.operations.wait_for_operation(
+ response.json()['operation'])
+ if 'status' in self.__dirty__:
+ del self.__dirty__[self.__dirty__.index('status')]
+ if self.ephemeral and state == 'stop':
+ self.client = None
+ else:
+ self.sync()
+
+ def state(self):
+ response = self.api.state.get()
+ state = InstanceState(**response.json()['metadata'])
+ return state
+
+ def start(self, timeout=30, force=True, wait=False):
+ """Start the instance."""
+ return self._set_state('start',
+ timeout=timeout,
+ force=force,
+ wait=wait)
+
+ def stop(self, timeout=30, force=True, wait=False):
+ """Stop the instance."""
+ return self._set_state('stop',
+ timeout=timeout,
+ force=force,
+ wait=wait)
+
+ def restart(self, timeout=30, force=True, wait=False):
+ """Restart the instance."""
+ return self._set_state('restart',
+ timeout=timeout,
+ force=force,
+ wait=wait)
+
+ def freeze(self, timeout=30, force=True, wait=False):
+ """Freeze the instance."""
+ return self._set_state('freeze',
+ timeout=timeout,
+ force=force,
+ wait=wait)
+
+ def unfreeze(self, timeout=30, force=True, wait=False):
+ """Unfreeze the instance."""
+ return self._set_state('unfreeze',
+ timeout=timeout,
+ force=force,
+ wait=wait)
+
+ def execute(
+ self, commands, environment=None, encoding=None, decode=True,
+ stdin_payload=None, stdin_encoding="utf-8",
+ stdout_handler=None, stderr_handler=None):
+ """Execute a command on the instance. stdout and stderr are buffered if
+ no handler is given.
+
+ :param commands: The command and arguments as a list of strings
+ :type commands: [str]
+ :param environment: The environment variables to pass with the command
+ :type environment: {str: str}
+ :param encoding: The encoding to use for stdout/stderr if the param
+ decode is True. If encoding is None, then no override is
+ performed and whatever the existing encoding from LXD is used.
+ :type encoding: str
+ :param decode: Whether to decode the stdout/stderr or just return the
+ raw buffers.
+ :type decode: bool
+ :param stdin_payload: Payload to pass via stdin
+ :type stdin_payload: Can be a file, string, bytearray, generator or
+ ws4py Message object
+ :param stdin_encoding: Encoding to pass text to stdin (default utf-8)
+ :param stdout_handler: Callable than receive as first parameter each
+ message received via stdout
+ :type stdout_handler: Callable[[str], None]
+ :param stderr_handler: Callable than receive as first parameter each
+ message received via stderr
+ :type stderr_handler: Callable[[str], None]
+ :raises ValueError: if the ws4py library is not installed.
+ :returns: A tuple of `(exit_code, stdout, stderr)`
+ :rtype: _InstanceExecuteResult() namedtuple
+ """
+ if not _ws4py_installed:
+ raise ValueError(
+ 'This feature requires the optional ws4py library.')
+ if isinstance(commands, six.string_types):
+ raise TypeError("First argument must be a list.")
+ if environment is None:
+ environment = {}
+
+ response = self.api['exec'].post(json={
+ 'command': commands,
+ 'environment': environment,
+ 'wait-for-websocket': True,
+ 'interactive': False,
+ })
+
+ fds = response.json()['metadata']['metadata']['fds']
+ operation_id = \
+ Operation.extract_operation_id(response.json()['operation'])
+ parsed = parse.urlparse(
+ self.client.api.operations[operation_id].websocket._api_endpoint)
+
+ with managers.web_socket_manager(WebSocketManager()) as manager:
+ stdin = _StdinWebsocket(
+ self.client.websocket_url, payload=stdin_payload,
+ encoding=stdin_encoding
+ )
+ stdin.resource = '{}?secret={}'.format(parsed.path, fds['0'])
+ stdin.connect()
+ stdout = _CommandWebsocketClient(
+ manager, self.client.websocket_url,
+ encoding=encoding, decode=decode, handler=stdout_handler)
+ stdout.resource = '{}?secret={}'.format(parsed.path, fds['1'])
+ stdout.connect()
+ stderr = _CommandWebsocketClient(
+ manager, self.client.websocket_url,
+ encoding=encoding, decode=decode, handler=stderr_handler)
+ stderr.resource = '{}?secret={}'.format(parsed.path, fds['2'])
+ stderr.connect()
+
+ manager.start()
+
+ # watch for the end of the command:
+ while True:
+ operation = self.client.operations.get(operation_id)
+ if 'return' in operation.metadata:
+ break
+ time.sleep(.5) # pragma: no cover
+
+ try:
+ stdin.close()
+ except BrokenPipeError:
+ pass
+
+ stdout.finish_soon()
+ stderr.finish_soon()
+ manager.close_all()
+
+ while not stdout.finished or not stderr.finished:
+ time.sleep(.1) # progma: no cover
+
+ manager.stop()
+ manager.join()
+
+ return _InstanceExecuteResult(
+ operation.metadata['return'], stdout.data, stderr.data)
+
+ def raw_interactive_execute(self, commands, environment=None):
+ """Execute a command on the instance interactively and returns
+ urls to websockets. The urls contain a secret uuid, and can be accesses
+ without further authentication. The caller has to open and manage
+ the websockets themselves.
+
+ :param commands: The command and arguments as a list of strings
+ (most likely a shell)
+ :type commands: [str]
+ :param environment: The environment variables to pass with the command
+ :type environment: {str: str}
+ :returns: Two urls to an interactive websocket and a control socket
+ :rtype: {'ws':str,'control':str}
+ """
+ if isinstance(commands, six.string_types):
+ raise TypeError("First argument must be a list.")
+
+ if environment is None:
+ environment = {}
+
+ response = self.api['exec'].post(json={
+ 'command': commands,
+ 'environment': environment,
+ 'wait-for-websocket': True,
+ 'interactive': True,
+ })
+
+ fds = response.json()['metadata']['metadata']['fds']
+ operation_id = response.json()['operation']\
+ .split('/')[-1].split('?')[0]
+ parsed = parse.urlparse(
+ self.client.api.operations[operation_id].websocket._api_endpoint)
+
+ return {'ws': '{}?secret={}'.format(parsed.path, fds['0']),
+ 'control': '{}?secret={}'.format(parsed.path, fds['control'])}
+
+ def migrate(self, new_client, live=False, wait=False):
+ """Migrate a instance.
+
+ Destination host information is contained in the client
+ connection passed in.
+
+ If the `live` param is True, then a live migration is attempted,
+ otherwise a non live one is running.
+
+ If the instance is running for live migration, it either must be shut
+ down first or criu must be installed on the source and destination
+ machines and the `live` param should be True.
+
+ :param new_client: the pylxd client connection to migrate the instance
+ to.
+ :type new_client: :class:`pylxd.client.Client`
+ :param live: whether to perform a live migration
+ :type live: bool
+ :param wait: if True, wait for the migration to complete
+ :type wait: bool
+ :raises: LXDAPIException if any of the API calls fail.
+ :raises: ValueError if source of target are local connections
+ :returns: the response from LXD of the new instance (the target of the
+ migration and not the operation if waited on.)
+ :rtype: :class:`requests.Response`
+ """
+ if self.api.scheme in ('http+unix',):
+ raise ValueError('Cannot migrate from a local client connection')
+
+ if self.status_code == 103:
+ try:
+ res = getattr(new_client, self._endpoint).create(
+ self.generate_migration_data(live), wait=wait)
+ except LXDAPIException as e:
+ if e.response.status_code == 103:
+ self.delete()
+ return getattr(new_client, self._endpoint).get(self.name)
+ else:
+ raise e
+ else:
+ res = getattr(new_client, self._endpoint).create(
+ self.generate_migration_data(live), wait=wait)
+ self.delete()
+ return res
+
+ def generate_migration_data(self, live=False):
+ """Generate the migration data.
+
+ This method can be used to handle migrations where the client
+ connection uses the local unix socket. For more information on
+ migration, see `Instance.migrate`.
+
+ :param live: Whether to include "live": "true" in the migration
+ :type live: bool
+ :raises: LXDAPIException if the request to migrate fails
+ :returns: dictionary of migration data suitable to send to an new
+ client to complete a migration.
+ :rtype: Dict[str, ANY]
+ """
+ self.sync() # Make sure the object isn't stale
+ _json = {'migration': True}
+ if live:
+ _json['live'] = True
+ response = self.api.post(json=_json)
+ operation = self.client.operations.get(response.json()['operation'])
+ operation_url = self.client.api.operations[operation.id]._api_endpoint
+ secrets = response.json()['metadata']['metadata']
+ cert = self.client.host_info['environment']['certificate']
+
+ return {
+ 'name': self.name,
+ 'architecture': self.architecture,
+ 'config': self.config,
+ 'devices': self.devices,
+ 'epehemeral': self.ephemeral,
+ 'default': self.profiles,
+ 'source': {
+ 'type': 'migration',
+ 'operation': operation_url,
+ 'mode': 'pull',
+ 'certificate': cert,
+ 'secrets': secrets,
+ }
+ }
+
+ def publish(self, public=False, wait=False):
+ """Publish a instance as an image.
+
+ The instance must be stopped in order publish it as an image. This
+ method does not enforce that constraint, so a LXDAPIException may be
+ raised if this method is called on a running instance.
+
+ If wait=True, an Image is returned.
+ """
+ data = {
+ 'public': public,
+ 'source': {
+ 'type': 'instance',
+ 'name': self.name,
+ }
+ }
+
+ response = self.client.api.images.post(json=data)
+ if wait:
+ operation = self.client.operations.wait_for_operation(
+ response.json()['operation'])
+
+ return self.client.images.get(operation.metadata['fingerprint'])
+
+ def restore_snapshot(self, snapshot_name, wait=False):
+ """Restore a snapshot using its name.
+
+ Attempts to restore a instance using a snapshot previously made. The
+ instance should be stopped, but the method does not enforce this
+ constraint, so an LXDAPIException may be raised if this method fails.
+
+ :param snapshot_name: the name of the snapshot to restore from
+ :type snapshot_name: str
+ :param wait: wait until the operation is completed.
+ :type wait: boolean
+ :raises: LXDAPIException if the the operation fails.
+ :returns: the original response from the restore operation (not the
+ operation result)
+ :rtype: :class:`requests.Response`
+ """
+ response = self.api.put(json={"restore": snapshot_name})
+ if wait:
+ self.client.operations.wait_for_operation(
+ response.json()['operation'])
+ return response
+
+
+class _CommandWebsocketClient(WebSocketBaseClient): # pragma: no cover
+ """Handle a websocket for instance.execute(...) and manage decoding of the
+ returned values depending on 'decode' and 'encoding' parameters.
+ """
+
+ def __init__(self, manager, *args, **kwargs):
+ self.manager = manager
+ self.decode = kwargs.pop('decode', True)
+ self.encoding = kwargs.pop('encoding', None)
+ self.handler = kwargs.pop('handler', None)
+ self.message_encoding = None
+ self.finish_off = False
+ self.finished = False
+ self.last_message_empty = False
+ self.buffer = []
+ super(_CommandWebsocketClient, self).__init__(*args, **kwargs)
+
+ def handshake_ok(self):
+ self.manager.add(self)
+ self.buffer = []
+
+ def received_message(self, message):
+ if message.data is None or len(message.data) == 0:
+ self.last_message_empty = True
+ if self.finish_off:
+ self.finished = True
+ return
+ else:
+ self.last_message_empty = False
+ if message.encoding and self.message_encoding is None:
+ self.message_encoding = message.encoding
+ if self.handler:
+ self.handler(self._maybe_decode(message.data))
+ else:
+ self.buffer.append(message.data)
+ if self.finish_off and isinstance(message, BinaryMessage):
+ self.finished = True
+
+ def closed(self, code, reason=None):
+ self.finished = True
+
+ def finish_soon(self):
+ self.finish_off = True
+ if self.last_message_empty:
+ self.finished = True
+
+ def _maybe_decode(self, buffer):
+ if self.decode and buffer is not None:
+ if self.encoding:
+ return buffer.decode(self.encoding)
+ if self.message_encoding:
+ return buffer.decode(self.message_encoding)
+ # This is the backwards compatible "always decode to utf-8"
+ return buffer.decode('utf-8')
+ return buffer
+
+ @property
+ def data(self):
+ buffer = b''.join(self.buffer)
+ return self._maybe_decode(buffer)
+
+
+class _StdinWebsocket(WebSocketBaseClient): # pragma: no cover
+ """A websocket client for handling stdin.
+
+ Allow comunicate with instance commands via stdin
+ """
+
+ def __init__(self, url, payload=None, **kwargs):
+ self.encoding = kwargs.pop('encoding', None)
+ self.payload = payload
+ super(_StdinWebsocket, self).__init__(url, **kwargs)
+
+ def _smart_encode(self, msg):
+ if type(msg) == six.text_type and self.encoding:
+ return msg.encode(self.encoding)
+ return msg
+
+ def handshake_ok(self):
+ if self.payload:
+ if hasattr(self.payload, "read"):
+ self.send(
+ (self._smart_encode(line) for line in self.payload),
+ binary=True
+ )
+ else:
+ self.send(self._smart_encode(self.payload), binary=True)
+ self.send(b"", binary=False)
+
+
+class Snapshot(model.Model):
+ """A instance snapshot."""
+
+ name = model.Attribute()
+ created_at = model.Attribute()
+ stateful = model.Attribute()
+
+ instance = model.Parent()
+
+ @property
+ def api(self):
+ return self.client.api.instances[
+ self.instance.name].snapshots[self.name]
+
+ @classmethod
+ def get(cls, client, instance, name):
+ response = client.api.instances[
+ instance.name].snapshots[name].get()
+
+ snapshot = cls(
+ client, instance=instance,
+ **response.json()['metadata'])
+ # Snapshot names are namespaced in LXD, as
+ # instance-name/snapshot-name. We hide that implementation
+ # detail.
+ snapshot.name = snapshot.name.split('/')[-1]
+ return snapshot
+
+ @classmethod
+ def all(cls, client, instance):
+ response = client.api.instances[instance.name].snapshots.get()
+
+ return [cls(
+ client, name=snapshot.split('/')[-1],
+ instance=instance)
+ for snapshot in response.json()['metadata']]
+
+ @classmethod
+ def create(cls, client, instance, name, stateful=False, wait=False):
+ response = client.api.instances[instance.name].snapshots.post(json={
+ 'name': name, 'stateful': stateful})
+
+ snapshot = cls(client, instance=instance, name=name)
+ if wait:
+ client.operations.wait_for_operation(response.json()['operation'])
+ return snapshot
+
+ def rename(self, new_name, wait=False):
+ """Rename a snapshot."""
+ response = self.api.post(json={'name': new_name})
+ if wait:
+ self.client.operations.wait_for_operation(
+ response.json()['operation'])
+ self.name = new_name
+
+ def publish(self, public=False, wait=False):
+ """Publish a snapshot as an image.
+
+ If wait=True, an Image is returned.
+
+ This functionality is currently broken in LXD. Please see
+ https://github.com/lxc/lxd/issues/2201 - The implementation
+ here is mostly a guess. Once that bug is fixed, we can verify
+ that this works, or file a bug to fix it appropriately.
+ """
+ data = {
+ 'public': public,
+ 'source': {
+ 'type': 'snapshot',
+ 'name': '{}/{}'.format(self.instance.name, self.name),
+ }
+ }
+
+ response = self.client.api.images.post(json=data)
+ if wait:
+ operation = self.client.operations.wait_for_operation(
+ response.json()['operation'])
+ return self.client.images.get(operation.metadata['fingerprint'])
+
+ def restore(self, wait=False):
+ """Restore this snapshot.
+
+ Attempts to restore a instance using this snapshot. The instance
+ should be stopped, but the method does not enforce this constraint, so
+ an LXDAPIException may be raised if this method fails.
+
+ :param wait: wait until the operation is completed.
+ :type wait: boolean
+ :raises: LXDAPIException if the the operation fails.
+ :returns: the original response from the restore operation (not the
+ operation result)
+ :rtype: :class:`requests.Response`
+ """
+ return self.instance.restore_snapshot(self.name, wait)
diff --git a/pylxd/models/storage_pool.py b/pylxd/models/storage_pool.py
index 8639b1d7..b50d298e 100644
--- a/pylxd/models/storage_pool.py
+++ b/pylxd/models/storage_pool.py
@@ -351,6 +351,10 @@ def all(cls, storage_pool):
# for each type, convert to the string that will work with GET
if _type == 'containers':
_type = 'container'
+ elif _type == 'virtual-machines':
+ _type = 'virtual-machine'
+ elif _type == 'instances':
+ _type = 'instance'
elif _type == 'images':
_type = 'image'
else:
diff --git a/pylxd/models/virtual_machine.py b/pylxd/models/virtual_machine.py
new file mode 100644
index 00000000..84f392be
--- /dev/null
+++ b/pylxd/models/virtual_machine.py
@@ -0,0 +1,19 @@
+# Copyright (c) 2020 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+from pylxd.models import Instance
+
+
+class VirtualMachine(Instance):
+
+ _endpoint = 'virtual-machines'
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index 5d44550e..c46e25e5 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -1,14 +1,14 @@
import json
-def containers_POST(request, context):
+def instances_POST(request, context):
context.status_code = 202
return json.dumps({
'type': 'async',
'operation': '/1.0/operations/operation-abc?project=default'})
-def container_POST(request, context):
+def instance_POST(request, context):
context.status_code = 202
if not request.json().get('migration', False):
return {
@@ -28,7 +28,7 @@ def container_POST(request, context):
}
-def container_PUT(request, context):
+def instance_PUT(request, context):
context.status_code = 202
return {
'type': 'async',
@@ -36,7 +36,7 @@ def container_PUT(request, context):
}
-def container_DELETE(request, context):
+def instance_DELETE(request, context):
context.status_code = 202
return json.dumps({
'type': 'async',
@@ -258,45 +258,45 @@ def snapshot_DELETE(request, context):
},
- # Containers
+ # Instances
{
'text': json.dumps({
'type': 'sync',
'metadata': [
- 'http://pylxd.test/1.0/containers/an-container',
+ 'http://pylxd.test/1.0/instances/an-instance',
]}),
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers$',
+ 'url': r'^http://pylxd.test/1.0/instances$',
},
{
'text': json.dumps({
'type': 'sync',
'metadata': [
- 'http://pylxd2.test/1.0/containers/an-container',
+ 'http://pylxd2.test/1.0/instances/an-instance',
]}),
'method': 'GET',
- 'url': r'^http://pylxd2.test/1.0/containers$',
+ 'url': r'^http://pylxd2.test/1.0/instances$',
},
{
- 'text': containers_POST,
+ 'text': instances_POST,
'method': 'POST',
- 'url': r'^http://pylxd2.test/1.0/containers$',
+ 'url': r'^http://pylxd2.test/1.0/instances$',
},
{
- 'text': containers_POST,
+ 'text': instances_POST,
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers$',
+ 'url': r'^http://pylxd.test/1.0/instances$',
},
{
- 'text': containers_POST,
+ 'text': instances_POST,
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers\?target=an-remote',
+ 'url': r'^http://pylxd.test/1.0/instances\?target=an-remote',
},
{
'json': {
'type': 'sync',
'metadata': {
- 'name': 'an-container',
+ 'name': 'an-instance',
'architecture': "x86_64",
'config': {
@@ -338,13 +338,13 @@ def snapshot_DELETE(request, context):
"makes it crash."
}},
'method': 'GET',
- 'url': r'^http://pylxd2.test/1.0/containers/an-container$',
+ 'url': r'^http://pylxd2.test/1.0/instances/an-instance$',
},
{
'json': {
'type': 'sync',
'metadata': {
- 'name': 'an-container',
+ 'name': 'an-instance',
'architecture': "x86_64",
'config': {
@@ -386,7 +386,7 @@ def snapshot_DELETE(request, context):
"makes it crash."
}},
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance$',
},
{
'json': {
@@ -419,13 +419,13 @@ def snapshot_DELETE(request, context):
'processes': 100,
}},
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/state$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/state$', # NOQA
},
{
'json': {
'type': 'sync',
'metadata': {
- 'name': 'an-new-remote-container',
+ 'name': 'an-new-remote-instance',
'architecture': "x86_64",
'config': {
@@ -442,7 +442,7 @@ def snapshot_DELETE(request, context):
"makes it crash."
}},
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-new-remote-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-new-remote-instance$',
},
{
'status_code': 202,
@@ -450,12 +450,12 @@ def snapshot_DELETE(request, context):
'type': 'async',
'operation': '/1.0/operations/operation-abc?project=default'},
'method': 'PUT',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/state$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/state$', # NOQA
},
{
- 'json': container_POST,
+ 'json': instance_POST,
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers/an-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance$',
},
{
'text': json.dumps({
@@ -463,12 +463,12 @@ def snapshot_DELETE(request, context):
'operation': '/1.0/operations/operation-abc?project=default'}),
'status_code': 202,
'method': 'PUT',
- 'url': r'^http://pylxd.test/1.0/containers/an-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance$',
},
{
- 'text': container_DELETE,
+ 'text': instance_DELETE,
'method': 'DELETE',
- 'url': r'^http://pylxd.test/1.0/containers/an-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance$',
},
{
'json': {
@@ -486,23 +486,23 @@ def snapshot_DELETE(request, context):
'operation': '/1.0/operations/operation-abc?project=default'},
'status_code': 202,
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/exec$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/exec$', # NOQA
},
{
- 'json': container_PUT,
+ 'json': instance_PUT,
'method': 'PUT',
- 'url': r'^http://pylxd.test/1.0/containers/an-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance$',
},
- # Container Snapshots
+ # Instance Snapshots
{
'text': json.dumps({
'type': 'sync',
'metadata': [
- '/1.0/containers/an_container/snapshots/an-snapshot',
+ '/1.0/instances/an_instance/snapshots/an-snapshot',
]}),
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/snapshots$', # NOQA
},
{
'text': json.dumps({
@@ -510,17 +510,17 @@ def snapshot_DELETE(request, context):
'operation': '/1.0/operations/operation-abc?project=default'}),
'status_code': 202,
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/snapshots$', # NOQA
},
{
'text': json.dumps({
'type': 'sync',
'metadata': {
- 'name': 'an_container/an-snapshot',
+ 'name': 'an_instance/an-snapshot',
'stateful': False,
}}),
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots/an-snapshot$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/snapshots/an-snapshot$', # NOQA
},
{
'text': json.dumps({
@@ -528,33 +528,33 @@ def snapshot_DELETE(request, context):
'operation': '/1.0/operations/operation-abc?project=default'}),
'status_code': 202,
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots/an-snapshot$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/snapshots/an-snapshot$', # NOQA
},
{
'text': snapshot_DELETE,
'method': 'DELETE',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots/an-snapshot$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/snapshots/an-snapshot$', # NOQA
},
- # Container files
+ # Instance files
{
'text': 'This is a getted file',
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/files\?path=%2Ftmp%2Fgetted$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/files\?path=%2Ftmp%2Fgetted$', # NOQA
},
{
'text': '{"some": "value"}',
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/files\?path=%2Ftmp%2Fjson-get$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/files\?path=%2Ftmp%2Fjson-get$', # NOQA
},
{
'method': 'POST',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/files\?path=%2Ftmp%2Fputted$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/files\?path=%2Ftmp%2Fputted$', # NOQA
},
{
'method': 'DELETE',
- 'url': r'^http://pylxd.test/1.0/containers/an-container/files\?path=%2Ftmp%2Fputted$', # NOQA
+ 'url': r'^http://pylxd.test/1.0/instances/an-instance/files\?path=%2Ftmp%2Fputted$', # NOQA
},
@@ -842,9 +842,9 @@ def snapshot_DELETE(request, context):
'json': {
'type': 'sync',
'metadata': [
- '/1.0/storage-pools/default/volumes/containers/c1',
- '/1.0/storage-pools/default/volumes/containers/c2',
- '/1.0/storage-pools/default/volumes/containers/c3',
+ '/1.0/storage-pools/default/volumes/instances/c1',
+ '/1.0/storage-pools/default/volumes/instances/c2',
+ '/1.0/storage-pools/default/volumes/instances/c3',
'/1.0/storage-pools/default/volumes/images/i1',
'/1.0/storage-pools/default/volumes/images/i2',
'/1.0/storage-pools/default/volumes/custom/cu1',
diff --git a/pylxd/tests/models/test_container.py b/pylxd/tests/models/test_instance.py
similarity index 59%
rename from pylxd/tests/models/test_container.py
rename to pylxd/tests/models/test_instance.py
index 34f6829f..3bc652ba 100644
--- a/pylxd/tests/models/test_container.py
+++ b/pylxd/tests/models/test_instance.py
@@ -12,25 +12,25 @@
from pylxd.tests import testing
-class TestContainer(testing.PyLXDTestCase):
- """Tests for pylxd.models.Container."""
+class TestInstance(testing.PyLXDTestCase):
+ """Tests for pylxd.models.Instance."""
def test_all(self):
- """A list of all containers are returned."""
- containers = models.Container.all(self.client)
+ """A list of all instances are returned."""
+ instances = models.Instance.all(self.client)
- self.assertEqual(1, len(containers))
+ self.assertEqual(1, len(instances))
def test_get(self):
- """Return a container."""
- name = 'an-container'
+ """Return a instance."""
+ name = 'an-instance'
- an_container = models.Container.get(self.client, name)
+ an_instance = models.Instance.get(self.client, name)
- self.assertEqual(name, an_container.name)
+ self.assertEqual(name, an_instance.name)
def test_get_not_found(self):
- """LXDAPIException is raised when the container doesn't exist."""
+ """LXDAPIException is raised when the instance doesn't exist."""
def not_found(request, context):
context.status_code = 404
return json.dumps({
@@ -40,14 +40,14 @@ def not_found(request, context):
self.add_rule({
'text': not_found,
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-missing-instance$',
})
- name = 'an-missing-container'
+ name = 'an-missing-instance'
self.assertRaises(
exceptions.LXDAPIException,
- models.Container.get, self.client, name)
+ models.Instance.get, self.client, name)
def test_get_error(self):
"""LXDAPIException is raised when the LXD API errors."""
@@ -60,42 +60,42 @@ def not_found(request, context):
self.add_rule({
'text': not_found,
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-missing-instance$',
})
- name = 'an-missing-container'
+ name = 'an-missing-instance'
self.assertRaises(
exceptions.LXDAPIException,
- models.Container.get, self.client, name)
+ models.Instance.get, self.client, name)
def test_create(self):
- """A new container is created."""
- config = {'name': 'an-new-container'}
+ """A new instance is created."""
+ config = {'name': 'an-new-instance'}
- an_new_container = models.Container.create(
+ an_new_instance = models.Instance.create(
self.client, config, wait=True)
- self.assertEqual(config['name'], an_new_container.name)
+ self.assertEqual(config['name'], an_new_instance.name)
def test_create_remote(self):
- """A new container is created at target."""
- config = {'name': 'an-new-remote-container'}
+ """A new instance is created at target."""
+ config = {'name': 'an-new-remote-instance'}
- an_new_remote_container = models.Container.create(
+ an_new_remote_instance = models.Instance.create(
self.client, config, wait=True, target="an-remote")
- self.assertEqual(config['name'], an_new_remote_container.name)
- self.assertEqual("an-remote", an_new_remote_container.location)
+ self.assertEqual(config['name'], an_new_remote_instance.name)
+ self.assertEqual("an-remote", an_new_remote_instance.location)
def test_exists(self):
- """A container exists."""
- name = 'an-container'
+ """A instance exists."""
+ name = 'an-instance'
- self.assertTrue(models.Container.exists(self.client, name))
+ self.assertTrue(models.Instance.exists(self.client, name))
def test_not_exists(self):
- """A container exists."""
+ """A instance exists."""
def not_found(request, context):
context.status_code = 404
return json.dumps({
@@ -105,24 +105,24 @@ def not_found(request, context):
self.add_rule({
'text': not_found,
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-missing-instance$',
})
- name = 'an-missing-container'
+ name = 'an-missing-instance'
- self.assertFalse(models.Container.exists(self.client, name))
+ self.assertFalse(models.Instance.exists(self.client, name))
def test_fetch(self):
- """A sync updates the properties of a container."""
- an_container = models.Container(
- self.client, name='an-container')
+ """A sync updates the properties of a instance."""
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- an_container.sync()
+ an_instance.sync()
- self.assertTrue(an_container.ephemeral)
+ self.assertTrue(an_instance.ephemeral)
def test_fetch_not_found(self):
- """LXDAPIException is raised on a 404 for updating container."""
+ """LXDAPIException is raised on a 404 for updating instance."""
def not_found(request, context):
context.status_code = 404
return json.dumps({
@@ -132,13 +132,13 @@ def not_found(request, context):
self.add_rule({
'text': not_found,
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-missing-instance$',
})
- an_container = models.Container(
- self.client, name='an-missing-container')
+ an_instance = models.Instance(
+ self.client, name='an-missing-instance')
- self.assertRaises(exceptions.LXDAPIException, an_container.sync)
+ self.assertRaises(exceptions.LXDAPIException, an_instance.sync)
def test_fetch_error(self):
"""LXDAPIException is raised on error."""
@@ -151,82 +151,82 @@ def not_found(request, context):
self.add_rule({
'text': not_found,
'method': 'GET',
- 'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',
+ 'url': r'^http://pylxd.test/1.0/instances/an-missing-instance$',
})
- an_container = models.Container(
- self.client, name='an-missing-container')
+ an_instance = models.Instance(
+ self.client, name='an-missing-instance')
- self.assertRaises(exceptions.LXDAPIException, an_container.sync)
+ self.assertRaises(exceptions.LXDAPIException, an_instance.sync)
def test_update(self):
- """A container is updated."""
- an_container = models.Container(
- self.client, name='an-container')
- an_container.architecture = 1
- an_container.config = {}
- an_container.created_at = 1
- an_container.devices = {}
- an_container.ephemeral = 1
- an_container.expanded_config = {}
- an_container.expanded_devices = {}
- an_container.profiles = 1
- an_container.status = 1
-
- an_container.save(wait=True)
-
- self.assertTrue(an_container.ephemeral)
+ """A instance is updated."""
+ an_instance = models.Instance(
+ self.client, name='an-instance')
+ an_instance.architecture = 1
+ an_instance.config = {}
+ an_instance.created_at = 1
+ an_instance.devices = {}
+ an_instance.ephemeral = 1
+ an_instance.expanded_config = {}
+ an_instance.expanded_devices = {}
+ an_instance.profiles = 1
+ an_instance.status = 1
+
+ an_instance.save(wait=True)
+
+ self.assertTrue(an_instance.ephemeral)
def test_rename(self):
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- an_container.rename('an-renamed-container', wait=True)
+ an_instance.rename('an-renamed-instance', wait=True)
- self.assertEqual('an-renamed-container', an_container.name)
+ self.assertEqual('an-renamed-instance', an_instance.name)
def test_delete(self):
- """A container is deleted."""
+ """A instance is deleted."""
# XXX: rockstar (21 May 2016) - This just executes
# a code path. There should be an assertion here, but
# it's not clear how to assert that, just yet.
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- an_container.delete(wait=True)
+ an_instance.delete(wait=True)
@testing.requires_ws4py
- @mock.patch('pylxd.models.container._StdinWebsocket')
- @mock.patch('pylxd.models.container._CommandWebsocketClient')
+ @mock.patch('pylxd.models.instance._StdinWebsocket')
+ @mock.patch('pylxd.models.instance._CommandWebsocketClient')
def test_execute(self, _CommandWebsocketClient, _StdinWebsocket):
- """A command is executed on a container."""
+ """A command is executed on a instance."""
fake_websocket = mock.Mock()
fake_websocket.data = 'test\n'
_StdinWebsocket.return_value = fake_websocket
_CommandWebsocketClient.return_value = fake_websocket
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- result = an_container.execute(['echo', 'test'])
+ result = an_instance.execute(['echo', 'test'])
self.assertEqual(0, result.exit_code)
self.assertEqual('test\n', result.stdout)
@testing.requires_ws4py
- @mock.patch('pylxd.models.container._StdinWebsocket')
- @mock.patch('pylxd.models.container._CommandWebsocketClient')
+ @mock.patch('pylxd.models.instance._StdinWebsocket')
+ @mock.patch('pylxd.models.instance._CommandWebsocketClient')
def test_execute_with_env(self, _CommandWebsocketClient, _StdinWebsocket):
- """A command is executed on a container with custom env variables."""
+ """A command is executed on a instance with custom env variables."""
fake_websocket = mock.Mock()
fake_websocket.data = 'test\n'
_StdinWebsocket.return_value = fake_websocket
_CommandWebsocketClient.return_value = fake_websocket
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- result = an_container.execute(['echo', 'test'], environment={
+ result = an_instance.execute(['echo', 'test'], environment={
"DISPLAY": ":1"
})
@@ -235,31 +235,31 @@ def test_execute_with_env(self, _CommandWebsocketClient, _StdinWebsocket):
def test_execute_no_ws4py(self):
"""If ws4py is not installed, ValueError is raised."""
- from pylxd.models import container
- old_installed = container._ws4py_installed
- container._ws4py_installed = False
+ from pylxd.models import instance
+ old_installed = instance._ws4py_installed
+ instance._ws4py_installed = False
def cleanup():
- container._ws4py_installed = old_installed
+ instance._ws4py_installed = old_installed
self.addCleanup(cleanup)
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- self.assertRaises(ValueError, an_container.execute, ['echo', 'test'])
+ self.assertRaises(ValueError, an_instance.execute, ['echo', 'test'])
@testing.requires_ws4py
def test_execute_string(self):
"""A command passed as string raises a TypeError."""
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- self.assertRaises(TypeError, an_container.execute, 'apt-get update')
+ self.assertRaises(TypeError, an_instance.execute, 'apt-get update')
def test_raw_interactive_execute(self):
- an_container = models.Container(self.client, name='an-container')
+ an_instance = models.Instance(self.client, name='an-instance')
- result = an_container.raw_interactive_execute(['/bin/bash'])
+ result = an_instance.raw_interactive_execute(['/bin/bash'])
self.assertEqual(result['ws'],
'/1.0/operations/operation-abc/websocket?secret=abc')
@@ -267,10 +267,10 @@ def test_raw_interactive_execute(self):
'/1.0/operations/operation-abc/websocket?secret=jkl')
def test_raw_interactive_execute_env(self):
- an_container = models.Container(self.client, name='an-container')
+ an_instance = models.Instance(self.client, name='an-instance')
- result = an_container.raw_interactive_execute(['/bin/bash'],
- {"PATH": "/"})
+ result = an_instance.raw_interactive_execute(
+ ['/bin/bash'], {"PATH": "/"})
self.assertEqual(result['ws'],
'/1.0/operations/operation-abc/websocket?secret=abc')
@@ -279,27 +279,27 @@ def test_raw_interactive_execute_env(self):
def test_raw_interactive_execute_string(self):
"""A command passed as string raises a TypeError."""
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
self.assertRaises(TypeError,
- an_container.raw_interactive_execute,
+ an_instance.raw_interactive_execute,
'apt-get update')
def test_migrate(self):
- """A container is migrated."""
+ """A instance is migrated."""
from pylxd.client import Client
client2 = Client(endpoint='http://pylxd2.test')
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- an_migrated_container = an_container.migrate(client2)
+ an_migrated_instance = an_instance.migrate(client2)
- self.assertEqual('an-container', an_migrated_container.name)
- self.assertEqual(client2, an_migrated_container.client)
+ self.assertEqual('an-instance', an_migrated_instance.name)
+ self.assertEqual(client2, an_migrated_instance.client)
- @mock.patch('pylxd.models.container.Container.generate_migration_data')
+ @mock.patch('pylxd.models.instance.Instance.generate_migration_data')
def test_migrate_exception_error(self, generate_migration_data):
"""LXDAPIException is raised in case of migration failure"""
from pylxd.client import Client
@@ -312,22 +312,22 @@ def generate_exception(*args, **kwargs):
generate_migration_data.side_effect = generate_exception
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
client2 = Client(endpoint='http://pylxd2.test')
- self.assertRaises(LXDAPIException, an_container.migrate, client2)
+ self.assertRaises(LXDAPIException, an_instance.migrate, client2)
- @mock.patch('pylxd.models.container.Container.generate_migration_data')
+ @mock.patch('pylxd.models.instance.Instance.generate_migration_data')
def test_migrate_exception_running(self, generate_migration_data):
- """Migrated container already running on destination"""
+ """Migrated instance already running on destination"""
from pylxd.client import Client
from pylxd.exceptions import LXDAPIException
client2 = Client(endpoint='http://pylxd2.test')
- an_container = models.Container(
- self.client, name='an-container')
- an_container.status_code = 103
+ an_instance = models.Instance(
+ self.client, name='an-instance')
+ an_instance.status_code = 103
def generate_exception(*args, **kwargs):
response = mock.Mock()
@@ -336,37 +336,37 @@ def generate_exception(*args, **kwargs):
generate_migration_data.side_effect = generate_exception
- an_migrated_container = an_container.migrate(client2, live=True)
+ an_migrated_instance = an_instance.migrate(client2, live=True)
- self.assertEqual('an-container', an_migrated_container.name)
- self.assertEqual(client2, an_migrated_container.client)
+ self.assertEqual('an-instance', an_migrated_instance.name)
+ self.assertEqual(client2, an_migrated_instance.client)
generate_migration_data.assert_called_once_with(True)
def test_migrate_started(self):
- """A container is migrated."""
+ """A instance is migrated."""
from pylxd.client import Client
client2 = Client(endpoint='http://pylxd2.test')
- an_container = models.Container.get(self.client, name='an-container')
- an_container.status_code = 103
+ an_instance = models.Instance.get(self.client, name='an-instance')
+ an_instance.status_code = 103
- an_migrated_container = an_container.migrate(client2)
+ an_migrated_instance = an_instance.migrate(client2)
- self.assertEqual('an-container', an_migrated_container.name)
- self.assertEqual(client2, an_migrated_container.client)
+ self.assertEqual('an-instance', an_migrated_instance.name)
+ self.assertEqual(client2, an_migrated_instance.client)
def test_migrate_stopped(self):
- """A container is migrated."""
+ """A instance is migrated."""
from pylxd.client import Client
client2 = Client(endpoint='http://pylxd2.test')
- an_container = models.Container.get(self.client, name='an-container')
- an_container.status_code = 102
+ an_instance = models.Instance.get(self.client, name='an-instance')
+ an_instance.status_code = 102
- an_migrated_container = an_container.migrate(client2)
+ an_migrated_instance = an_instance.migrate(client2)
- self.assertEqual('an-container', an_migrated_container.name)
- self.assertEqual(client2, an_migrated_container.client)
+ self.assertEqual('an-instance', an_migrated_instance.name)
+ self.assertEqual(client2, an_migrated_instance.client)
@mock.patch('pylxd.client._APINode.get')
def test_migrate_local_client(self, get):
@@ -380,13 +380,13 @@ def test_migrate_local_client(self, get):
from pylxd.client import Client
client2 = Client(endpoint='http+unix://pylxd2.test')
- an_container = models.Container(
- client2, name='an-container')
+ an_instance = models.Instance(
+ client2, name='an-instance')
- self.assertRaises(ValueError, an_container.migrate, self.client)
+ self.assertRaises(ValueError, an_instance.migrate, self.client)
def test_publish(self):
- """Containers can be published."""
+ """Instances can be published."""
self.add_rule({
'text': json.dumps({
'type': 'sync',
@@ -402,10 +402,10 @@ def test_publish(self):
'url': r'^http://pylxd.test/1.0/operations/operation-abc$',
})
- an_container = models.Container(
- self.client, name='an-container')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
- image = an_container.publish(wait=True)
+ image = an_instance.publish(wait=True)
self.assertEqual(
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
@@ -413,80 +413,80 @@ def test_publish(self):
def test_restore_snapshot(self):
"""Snapshots can be restored"""
- an_container = models.Container(
- self.client, name='an-container')
- an_container.restore_snapshot('thing')
+ an_instance = models.Instance(
+ self.client, name='an-instance')
+ an_instance.restore_snapshot('thing')
-class TestContainerState(testing.PyLXDTestCase):
- """Tests for pylxd.models.ContainerState."""
+class TestInstanceState(testing.PyLXDTestCase):
+ """Tests for pylxd.models.InstanceState."""
def test_get(self):
- """Return a container."""
- name = 'an-container'
+ """Return a instance."""
+ name = 'an-instance'
- an_container = models.Container.get(self.client, name)
- state = an_container.state()
+ an_instance = models.Instance.get(self.client, name)
+ state = an_instance.state()
self.assertEqual('Running', state.status)
self.assertEqual(103, state.status_code)
def test_start(self):
- """A container is started."""
- an_container = models.Container.get(self.client, 'an-container')
+ """A instance is started."""
+ an_instance = models.Instance.get(self.client, 'an-instance')
- an_container.start(wait=True)
+ an_instance.start(wait=True)
def test_stop(self):
- """A container is stopped."""
- an_container = models.Container.get(self.client, 'an-container')
+ """A instance is stopped."""
+ an_instance = models.Instance.get(self.client, 'an-instance')
- an_container.stop()
+ an_instance.stop()
def test_restart(self):
- """A container is restarted."""
- an_container = models.Container.get(self.client, 'an-container')
+ """A instance is restarted."""
+ an_instance = models.Instance.get(self.client, 'an-instance')
- an_container.restart()
+ an_instance.restart()
def test_freeze(self):
- """A container is suspended."""
- an_container = models.Container.get(self.client, 'an-container')
+ """A instance is suspended."""
+ an_instance = models.Instance.get(self.client, 'an-instance')
- an_container.freeze()
+ an_instance.freeze()
def test_unfreeze(self):
- """A container is resumed."""
- an_container = models.Container.get(self.client, 'an-container')
+ """A instance is resumed."""
+ an_instance = models.Instance.get(self.client, 'an-instance')
- an_container.unfreeze()
+ an_instance.unfreeze()
-class TestContainerSnapshots(testing.PyLXDTestCase):
- """Tests for pylxd.models.Container.snapshots."""
+class TestInstanceSnapshots(testing.PyLXDTestCase):
+ """Tests for pylxd.models.Instance.snapshots."""
def setUp(self):
- super(TestContainerSnapshots, self).setUp()
- self.container = models.Container.get(self.client, 'an-container')
+ super(TestInstanceSnapshots, self).setUp()
+ self.instance = models.Instance.get(self.client, 'an-instance')
def test_get(self):
"""Return a specific snapshot."""
- snapshot = self.container.snapshots.get('an-snapshot')
+ snapshot = self.instance.snapshots.get('an-snapshot')
self.assertEqual('an-snapshot', snapshot.name)
def test_all(self):
"""Return all snapshots."""
- snapshots = self.container.snapshots.all()
+ snapshots = self.instance.snapshots.all()
self.assertEqual(1, len(snapshots))
self.assertEqual('an-snapshot', snapshots[0].name)
self.assertEqual(self.client, snapshots[0].client)
- self.assertEqual(self.container, snapshots[0].container)
+ self.assertEqual(self.instance, snapshots[0].instance)
def test_create(self):
"""Create a snapshot."""
- snapshot = self.container.snapshots.create(
+ snapshot = self.instance.snapshots.create(
'an-snapshot', stateful=True, wait=True)
self.assertEqual('an-snapshot', snapshot.name)
@@ -497,12 +497,12 @@ class TestSnapshot(testing.PyLXDTestCase):
def setUp(self):
super(TestSnapshot, self).setUp()
- self.container = models.Container.get(self.client, 'an-container')
+ self.instance = models.Instance.get(self.client, 'an-instance')
def test_rename(self):
"""A snapshot is renamed."""
snapshot = models.Snapshot(
- self.client, container=self.container,
+ self.client, instance=self.instance,
name='an-snapshot')
snapshot.rename('an-renamed-snapshot', wait=True)
@@ -512,7 +512,7 @@ def test_rename(self):
def test_delete(self):
"""A snapshot is deleted."""
snapshot = models.Snapshot(
- self.client, container=self.container,
+ self.client, instance=self.instance,
name='an-snapshot')
snapshot.delete(wait=True)
@@ -530,12 +530,12 @@ def not_found(request, context):
self.add_rule({
'text': not_found,
'method': 'DELETE',
- 'url': (r'^http://pylxd.test/1.0/containers/'
- 'an-container/snapshots/an-snapshot$')
+ 'url': (r'^http://pylxd.test/1.0/instances/'
+ 'an-instance/snapshots/an-snapshot$')
})
snapshot = models.Snapshot(
- self.client, container=self.container,
+ self.client, instance=self.instance,
name='an-snapshot')
self.assertRaises(exceptions.LXDAPIException, snapshot.delete)
@@ -558,7 +558,7 @@ def test_publish(self):
})
snapshot = models.Snapshot(
- self.client, container=self.container,
+ self.client, instance=self.instance,
name='an-snapshot')
image = snapshot.publish(wait=True)
@@ -570,24 +570,24 @@ def test_publish(self):
def test_restore_snapshot(self):
"""Snapshots can be restored from the snapshot object"""
snapshot = models.Snapshot(
- self.client, container=self.container,
+ self.client, instance=self.instance,
name='an-snapshot')
snapshot.restore(wait=True)
class TestFiles(testing.PyLXDTestCase):
- """Tests for pylxd.models.Container.files."""
+ """Tests for pylxd.models.Instance.files."""
def setUp(self):
super(TestFiles, self).setUp()
- self.container = models.Container.get(self.client, 'an-container')
+ self.instance = models.Instance.get(self.client, 'an-instance')
def test_put_delete(self):
- """A file is put on the container and then deleted"""
+ """A file is put on the instance and then deleted"""
# we are mocked, so delete should initially not be available
- self.assertEqual(False, self.container.files.delete_available())
+ self.assertEqual(False, self.instance.files.delete_available())
self.assertRaises(exceptions.LXDAPIExtensionNotAvailable,
- self.container.files.delete, '/some/file')
+ self.instance.files.delete, '/some/file')
# Now insert delete
self.add_rule({
'text': json.dumps({
@@ -605,15 +605,15 @@ def test_put_delete(self):
# Update hostinfo
self.client.host_info = self.client.api.get().json()['metadata']
- self.assertEqual(True, self.container.files.delete_available())
+ self.assertEqual(True, self.instance.files.delete_available())
# mock out the delete rule:
self.add_rule({
'method': 'DELETE',
- 'url': (r'^http://pylxd.test/1.0/containers/an-container/files'
+ 'url': (r'^http://pylxd.test/1.0/instances/an-instance/files'
r'\?path=%2Fsome%2Ffile$')
})
- self.container.files.delete('/some/file')
+ self.instance.files.delete('/some/file')
# now check that an error (non 200) causes an exception
def responder(request, context):
@@ -622,11 +622,11 @@ def responder(request, context):
self.add_rule({
'text': responder,
'method': 'DELETE',
- 'url': (r'^http://pylxd.test/1.0/containers/an-container/files'
+ 'url': (r'^http://pylxd.test/1.0/instances/an-instance/files'
r'\?path=%2Fsome%2Ffile%2Fnot%2Ffound$')
})
with self.assertRaises(exceptions.LXDAPIException):
- self.container.files.delete('/some/file/not/found')
+ self.instance.files.delete('/some/file/not/found')
def test_put_mode_uid_gid(self):
"""Should be able to set the mode, uid and gid of a file"""
@@ -640,28 +640,28 @@ def capture(request, context):
self.add_rule({
'text': capture,
'method': 'POST',
- 'url': (r'^http://pylxd.test/1.0/containers/an-container/files'
+ 'url': (r'^http://pylxd.test/1.0/instances/an-instance/files'
r'\?path=%2Ftmp%2Fputted$'),
})
data = 'The quick brown fox'
# start with an octal mode
- self.container.files.put('/tmp/putted', data, mode=0o123, uid=1, gid=2)
+ self.instance.files.put('/tmp/putted', data, mode=0o123, uid=1, gid=2)
headers = _capture['headers']
self.assertEqual(headers['X-LXD-mode'], '0123')
self.assertEqual(headers['X-LXD-uid'], '1')
self.assertEqual(headers['X-LXD-gid'], '2')
# use a str mode this type
- self.container.files.put('/tmp/putted', data, mode='456')
+ self.instance.files.put('/tmp/putted', data, mode='456')
headers = _capture['headers']
self.assertEqual(headers['X-LXD-mode'], '0456')
# check that mode='0644' also works (i.e. already has 0 prefix)
- self.container.files.put('/tmp/putted', data, mode='0644')
+ self.instance.files.put('/tmp/putted', data, mode='0644')
headers = _capture['headers']
self.assertEqual(headers['X-LXD-mode'], '0644')
# check that assertion is raised
with self.assertRaises(ValueError):
- self.container.files.put('/tmp/putted', data, mode=object)
+ self.instance.files.put('/tmp/putted', data, mode=object)
def test_recursive_put(self):
@@ -691,8 +691,8 @@ def capture(request, context):
context.status_code = 200
with tempdir() as _dir:
- base = (r'^http://pylxd.test/1.0/containers/'
- r'an-container/files\?path=')
+ base = (r'^http://pylxd.test/1.0/instances/'
+ r'an-instance/files\?path=')
rules = [
{
'text': capture,
@@ -721,7 +721,7 @@ def capture(request, context):
create_file(_dir, 'file1', "This is file1")
create_file(_dir, 'dir/file2', "This is file2")
- self.container.files.recursive_put(_dir, './target/')
+ self.instance.files.recursive_put(_dir, './target/')
self.assertEqual(_captures[0]['headers']['X-LXD-type'],
'directory')
@@ -731,8 +731,8 @@ def capture(request, context):
self.assertEqual(_captures[3]['body'], b"This is file2")
def test_get(self):
- """A file is retrieved from the container."""
- data = self.container.files.get('/tmp/getted')
+ """A file is retrieved from the instance."""
+ data = self.instance.files.get('/tmp/getted')
self.assertEqual(b'This is a getted file', data)
@@ -743,14 +743,14 @@ def not_found(request, context):
rule = {
'text': not_found,
'method': 'GET',
- 'url': (r'^http://pylxd.test/1.0/containers/an-container/files'
+ 'url': (r'^http://pylxd.test/1.0/instances/an-instance/files'
r'\?path=%2Ftmp%2Fgetted$'),
}
self.add_rule(rule)
self.assertRaises(
exceptions.LXDAPIException,
- self.container.files.get, '/tmp/getted')
+ self.instance.files.get, '/tmp/getted')
def test_get_error(self):
"""LXDAPIException is raised on error."""
@@ -759,17 +759,17 @@ def not_found(request, context):
rule = {
'text': not_found,
'method': 'GET',
- 'url': (r'^http://pylxd.test/1.0/containers/an-container/files'
+ 'url': (r'^http://pylxd.test/1.0/instances/an-instance/files'
r'\?path=%2Ftmp%2Fgetted$'),
}
self.add_rule(rule)
self.assertRaises(
exceptions.LXDAPIException,
- self.container.files.get, '/tmp/getted')
+ self.instance.files.get, '/tmp/getted')
# for bug/281 -- getting an empty json file is interpreted as an API
# get rather than a raw get.
def test_get_json_file(self):
- data = self.container.files.get('/tmp/json-get')
+ data = self.instance.files.get('/tmp/json-get')
self.assertEqual(b'{"some": "value"}', data)
diff --git a/pylxd/tests/models/test_storage.py b/pylxd/tests/models/test_storage.py
index 054af364..ac2a4131 100644
--- a/pylxd/tests/models/test_storage.py
+++ b/pylxd/tests/models/test_storage.py
@@ -189,7 +189,7 @@ def test_all(self):
# assert that we decoded stuff reasonably well.
self.assertEqual(len(volumes), 6)
- self.assertEqual(volumes[0].type, 'container')
+ self.assertEqual(volumes[0].type, 'instance')
self.assertEqual(volumes[0].name, 'c1')
self.assertEqual(volumes[3].type, 'image')
self.assertEqual(volumes[3].name, 'i1')
More information about the lxc-devel
mailing list