Merge pull request #56 from juju/issues/51
authorTim Van Steenburgh <tvansteenburgh@gmail.com>
Fri, 24 Feb 2017 17:37:49 +0000 (12:37 -0500)
committerGitHub <noreply@github.com>
Fri, 24 Feb 2017 17:37:49 +0000 (12:37 -0500)
Implement Model.add_machine()

15 files changed:
docs/api/juju.rst
docs/narrative/model.rst
examples/add_machine.py [new file with mode: 0755]
juju/constraints.py
juju/machine.py
juju/model.py
juju/placement.py
tests/base.py
tests/client/test_client.py
tests/client/test_connection.py
tests/functional/__init__.py [new file with mode: 0644]
tests/functional/test_model.py [new file with mode: 0644]
tests/test_loop.py [deleted file]
tests/unit/test_loop.py [new file with mode: 0644]
tox.ini

index 37cf094..bb3de5f 100644 (file)
@@ -91,6 +91,14 @@ juju.juju module
     :undoc-members:
     :show-inheritance:
 
+juju.loop module
+----------------
+
+.. automodule:: juju.loop
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 juju.machine module
 -------------------
 
@@ -139,6 +147,14 @@ juju.unit module
     :undoc-members:
     :show-inheritance:
 
+juju.utils module
+-----------------
+
+.. automodule:: juju.utils
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 
 Module contents
 ---------------
index 79df011..74f7e84 100644 (file)
@@ -131,6 +131,50 @@ Example of creating a new model and then destroying it. See
   model = None
 
 
+Adding Machines and Containers
+------------------------------
+To add a machine or container, connect to a model and then call its
+:meth:`~juju.model.Model.add_machine` method. A
+:class:`~juju.machine.Machine` instance is returned. The machine id
+can be used to deploy a charm to a specific machine or container.
+
+.. code:: python
+
+  from juju.model import Model
+
+  model = Model()
+  await model.connect_current()
+
+  # add a new default machine
+  machine1 = await model.add_machine()
+
+  # add a machine with constraints, disks, and series specified
+  machine2 = await model.add_machine(
+      constraints={
+          'mem': 256 * MB,
+      },
+      disks=[{
+          'pool': 'rootfs',
+          'size': 10 * GB,
+          'count': 1,
+      }],
+      series='xenial',
+  )
+
+  # add a lxd container to machine2
+  machine3 = await model.add_machine(
+      'lxd:{}'.format(machine2.id))
+
+  # deploy charm to the lxd container
+  application = await model.deploy(
+      'ubuntu-10',
+      application_name='ubuntu',
+      series='xenial',
+      channel='stable',
+      to=machine3.id
+  )
+
+
 Reacting to Changes in a Model
 ------------------------------
 To watch for and respond to changes in a model, register an observer with the
diff --git a/examples/add_machine.py b/examples/add_machine.py
new file mode 100755 (executable)
index 0000000..391df00
--- /dev/null
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3.5
+
+"""
+This example:
+
+1. Connects to the current model
+2. Creates two machines and a lxd container
+3. Deploys charm to the lxd container
+
+"""
+import logging
+
+from juju import loop
+from juju.model import Model
+
+MB = 1
+GB = 1024
+
+
+async def main():
+    model = Model()
+    await model.connect_current()
+
+    try:
+        # add a new default machine
+        machine1 = await model.add_machine()
+        # add a machine with constraints, disks, and series
+        machine2 = await model.add_machine(
+            constraints={
+                'mem': 256 * MB,
+            },
+            disks=[{
+                'pool': 'rootfs',
+                'size': 10 * GB,
+                'count': 1,
+            }],
+            series='xenial',
+        )
+        # add a lxd container to machine2
+        machine3 = await model.add_machine(
+            'lxd:{}'.format(machine2.id))
+
+        # deploy charm to the lxd container
+        application = await model.deploy(
+            'ubuntu-10',
+            application_name='ubuntu',
+            series='xenial',
+            channel='stable',
+            to=machine3.id
+        )
+
+        await model.block_until(
+            lambda: all(unit.workload_status == 'active'
+                        for unit in application.units))
+
+        await application.remove()
+
+        await machine3.destroy(force=True)
+        await machine2.destroy(force=True)
+        await machine1.destroy(force=True)
+    finally:
+        await model.disconnect()
+
+
+logging.basicConfig(level=logging.DEBUG)
+ws_logger = logging.getLogger('websockets.protocol')
+ws_logger.setLevel(logging.INFO)
+
+loop.run(main())
index 97529e4..c551883 100644 (file)
@@ -21,6 +21,7 @@ import re
 MEM = re.compile('^[1-9][0-9]*[MGTP]$')
 
 # Multiplication factors to get Megabytes
+# https://github.com/juju/juju/blob/master/constraints/constraints.go#L666
 FACTORS = {
     "M": 1,
     "G": 1024,
@@ -31,6 +32,7 @@ FACTORS = {
 SNAKE1 = re.compile(r'(.)([A-Z][a-z]+)')
 SNAKE2 = re.compile('([a-z0-9])([A-Z])')
 
+
 def parse(constraints):
     """
     Constraints must be expressed as a string containing only spaces
index 04abc3b..44560bf 100644 (file)
@@ -10,6 +10,8 @@ class Machine(model.ModelEntity):
     async def destroy(self, force=False):
         """Remove this machine from the model.
 
+        Blocks until the machine is actually removed.
+
         """
         facade = client.ClientFacade()
         facade.connect(self.connection)
@@ -17,7 +19,9 @@ class Machine(model.ModelEntity):
         log.debug(
             'Destroying machine %s', self.id)
 
-        return await facade.DestroyMachines(force, [self.id])
+        await facade.DestroyMachines(force, [self.id])
+        return await self.model._wait(
+            'machine', self.id, 'remove')
     remove = destroy
 
     def run(self, command, timeout=None):
index 653a20c..1ab29b5 100644 (file)
@@ -23,6 +23,7 @@ from .delta import get_entity_delta
 from .delta import get_entity_class
 from .exceptions import DeadEntityException
 from .errors import JujuError, JujuAPIError
+from .placement import parse as parse_placement
 
 log = logging.getLogger(__name__)
 
@@ -670,7 +671,7 @@ class Model(object):
 
         :param entity_type: The entity's type.
         :param entity_id: The entity's id.
-        :param action: the type of action (e.g., 'add' or 'change')
+        :param action: the type of action (e.g., 'add', 'change', or 'remove')
         :param predicate: optional callable that must take as an
             argument a delta, and must return a boolean, indicating
             whether the delta contains the specific action we're looking
@@ -685,7 +686,9 @@ class Model(object):
 
         self.add_observer(callback, entity_type, action, entity_id, predicate)
         entity_id = await q.get()
-        return self.state._live_entity_map(entity_type)[entity_id]
+        # object might not be in the entity_map if we were waiting for a
+        # 'remove' action
+        return self.state._live_entity_map(entity_type).get(entity_id)
 
     async def _wait_for_new(self, entity_type, entity_id=None, predicate=None):
         """Wait for a new object to appear in the Model and return it.
@@ -719,9 +722,8 @@ class Model(object):
 
         return await self._wait('action', action_id, 'change', predicate)
 
-    def add_machine(
-            self, spec=None, constraints=None, disks=None, series=None,
-            count=1):
+    async def add_machine(
+            self, spec=None, constraints=None, disks=None, series=None):
         """Start a new, empty machine and optionally a container, or add a
         container to a machine.
 
@@ -729,25 +731,64 @@ class Model(object):
             Examples::
 
                 (None) - starts a new machine
-                'lxc' - starts a new machine with on lxc container
-                'lxc:4' - starts a new lxc container on machine 4
+                'lxd' - starts a new machine with one lxd container
+                'lxd:4' - starts a new lxd container on machine 4
                 'ssh:user@10.10.0.3' - manually provisions a machine with ssh
                 'zone=us-east-1a' - starts a machine in zone us-east-1s on AWS
                 'maas2.name' - acquire machine maas2.name on MAAS
-        :param constraints: Machine constraints
-        :type constraints: :class:`juju.Constraints`
-        :param list disks: List of disk :class:`constraints <juju.Constraints>`
-        :param str series: Series
-        :param int count: Number of machines to deploy
 
-        Supported container types are: lxc, lxd, kvm
+        :param dict constraints: Machine constraints
+            Example::
+
+                constraints={
+                    'mem': 256 * MB,
+                }
+
+        :param list disks: List of disk constraint dictionaries
+            Example::
+
+                disks=[{
+                    'pool': 'rootfs',
+                    'size': 10 * GB,
+                    'count': 1,
+                }]
+
+        :param str series: Series, e.g. 'xenial'
+
+        Supported container types are: lxd, kvm
 
         When deploying a container to an existing machine, constraints cannot
         be used.
 
         """
-        pass
-    add_machines = add_machine
+        params = client.AddMachineParams()
+        params.jobs = ['JobHostUnits']
+
+        if spec:
+            placement = parse_placement(spec)
+            if placement:
+                params.placement = placement[0]
+
+        if constraints:
+            params.constraints = client.Value.from_json(constraints)
+
+        if disks:
+            params.disks = [
+                client.Constraints.from_json(o) for o in disks]
+
+        if series:
+            params.series = series
+
+        # Submit the request.
+        client_facade = client.ClientFacade()
+        client_facade.connect(self.connection)
+        results = await client_facade.AddMachines([params])
+        error = results.machines[0].error
+        if error:
+            raise ValueError("Error adding machine: %s", error.message)
+        machine_id = results.machines[0].machine
+        log.debug('Added new machine %s', machine_id)
+        return await self._wait_for_new('machine', machine_id)
 
     async def add_relation(self, relation1, relation2):
         """Add a relation between two applications.
@@ -910,14 +951,11 @@ class Model(object):
         :param dict resources: <resource name>:<file path> pairs
         :param str series: Series on which to deploy
         :param dict storage: Storage constraints TODO how do these look?
-        :param to: Placement directive. Use placement.parse to generate it.
-            For example:
-
-            from juju import placement
+        :param to: Placement directive as a string. For example:
 
-            placement.parse('23') - place on machine 23
-            placement.parse('lxc:7') - place in new lxc container on machine 7
-            placement.parse('24/lxc/3') - place in container 3 on machine 24
+            '23' - place on machine 23
+            'lxd:7' - place in new lxd container on machine 7
+            '24/lxd/3' - place in container 3 on machine 24
 
             If None, a new machine is provisioned.
 
@@ -930,9 +968,7 @@ class Model(object):
 
         """
         if to:
-            placement = [
-                client.Placement(**p) for p in to
-            ]
+            placement = parse_placement(to)
         else:
             placement = []
 
@@ -1000,11 +1036,11 @@ class Model(object):
                 constraints=parse_constraints(constraints),
                 endpoint_bindings=bind,
                 num_units=num_units,
-                placement=placement,
                 resources=resources,
                 series=series,
                 storage=storage,
             )
+            app.placement = placement
 
             result = await app_facade.Deploy([app])
             errors = [r.error.message for r in result.results if r.error]
index 561bc40..5ee9ba6 100644 (file)
@@ -41,10 +41,10 @@ def parse(directive):
         return [client.Placement(scope=MACHINE_SCOPE, directive=directive)]
 
     if "/" in directive:
-        machine, container, container_num = directive.split("/")
+        # e.g. "0/lxd/0"
+        # https://github.com/juju/juju/blob/master/instance/placement_test.go#L29
         return [
-            client.Placement(scope=MACHINE_SCOPE, directive=machine),
-            client.Placement(scope=container, directive=container_num)
+            client.Placement(scope=MACHINE_SCOPE, directive=directive),
         ]
 
     # Planner has probably given us a container type. Leave it up to
index 35003cb..382da43 100644 (file)
@@ -1,7 +1,10 @@
+import uuid
 import subprocess
 
 import pytest
 
+from juju.controller import Controller
+
 
 def is_bootstrapped():
     result = subprocess.run(['juju', 'switch'], stdout=subprocess.PIPE)
@@ -12,3 +15,23 @@ def is_bootstrapped():
 bootstrapped = pytest.mark.skipif(
     not is_bootstrapped(),
     reason='bootstrapped Juju environment required')
+
+
+class CleanModel():
+    def __init__(self):
+        self.controller = None
+        self.model = None
+
+    async def __aenter__(self):
+        self.controller = Controller()
+        await self.controller.connect_current()
+
+        model_name = 'model-{}'.format(uuid.uuid4())
+        self.model = await self.controller.add_model(model_name)
+
+        return self.model
+
+    async def __aexit__(self, exc_type, exc, tb):
+        await self.model.disconnect()
+        await self.controller.destroy_model(self.model.info.uuid)
+        await self.controller.disconnect()
index 8598878..ca2637f 100644 (file)
@@ -1,26 +1,22 @@
-import asyncio
-import unittest
+import pytest
 
-from juju.client.connection import Connection
 from juju.client import client
 
-from ..base import bootstrapped
+from .. import base
 
 
-@bootstrapped
-class UserManagerTest(unittest.TestCase):
-    def test_user_info(self):
-        loop = asyncio.get_event_loop()
-        conn = loop.run_until_complete(
-            Connection.connect_current())
-        conn = loop.run_until_complete(
-            conn.controller())
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_user_info(event_loop):
+    async with base.CleanModel() as model:
+        controller_conn = await model.connection.controller()
 
         um = client.UserManagerFacade()
-        um.connect(conn)
-        result = loop.run_until_complete(
-            um.UserInfo([client.Entity('user-admin')], True))
+        um.connect(controller_conn)
+        result = await um.UserInfo(
+            [client.Entity('user-admin')], True)
+        await controller_conn.close()
 
-        self.assertIsInstance(result, client.UserInfoResults)
+        assert isinstance(result, client.UserInfoResults)
         for r in result.results:
-            self.assertIsInstance(r, client.UserInfoResult)
+            assert isinstance(r, client.UserInfoResult)
index 18b5863..9c61759 100644 (file)
@@ -1,15 +1,14 @@
-import asyncio
-import unittest
+import pytest
 
 from juju.client.connection import Connection
-from ..base import bootstrapped
+from .. import base
 
 
-@bootstrapped
-class FunctionalConnectionTest(unittest.TestCase):
-    def test_connect_current(self):
-        loop = asyncio.get_event_loop()
-        conn = loop.run_until_complete(
-            Connection.connect_current())
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_connect_current(event_loop):
+    async with base.CleanModel():
+        conn = await Connection.connect_current()
 
-        self.assertIsInstance(conn, Connection)
+        assert isinstance(conn, Connection)
+        await conn.close()
diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py
new file mode 100644 (file)
index 0000000..9ae9204
--- /dev/null
@@ -0,0 +1,45 @@
+import pytest
+
+from .. import base
+
+MB = 1
+GB = 1024
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_machine(event_loop):
+    from juju.machine import Machine
+
+    async with base.CleanModel() as model:
+        # add a new default machine
+        machine1 = await model.add_machine()
+
+        # add a machine with constraints, disks, and series
+        machine2 = await model.add_machine(
+            constraints={
+                'mem': 256 * MB,
+            },
+            disks=[{
+                'pool': 'rootfs',
+                'size': 10 * GB,
+                'count': 1,
+            }],
+            series='xenial',
+        )
+
+        # add a lxd container to machine2
+        machine3 = await model.add_machine(
+            'lxd:{}'.format(machine2.id))
+
+        for m in (machine1, machine2, machine3):
+            assert isinstance(m, Machine)
+
+        assert len(model.machines) == 3
+
+        await machine3.destroy(force=True)
+        await machine2.destroy(force=True)
+        res = await machine1.destroy(force=True)
+
+        assert res is None
+        assert len(model.machines) == 0
diff --git a/tests/test_loop.py b/tests/test_loop.py
deleted file mode 100644 (file)
index ad043fc..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import unittest
-import juju.loop
-
-
-class TestLoop(unittest.TestCase):
-    def test_run(self):
-        async def _test():
-            return 'success'
-        self.assertEqual(juju.loop.run(_test()), 'success')
-
-    def test_run_interrupt(self):
-        async def _test():
-            juju.loop.run._sigint = True
-        self.assertRaises(KeyboardInterrupt, juju.loop.run, _test())
-
-    def test_run_exception(self):
-        async def _test():
-            raise ValueError()
-        self.assertRaises(ValueError, juju.loop.run, _test())
diff --git a/tests/unit/test_loop.py b/tests/unit/test_loop.py
new file mode 100644 (file)
index 0000000..f12368e
--- /dev/null
@@ -0,0 +1,30 @@
+import asyncio
+import unittest
+import juju.loop
+
+
+class TestLoop(unittest.TestCase):
+    def setUp(self):
+        # new event loop for each test
+        policy = asyncio.get_event_loop_policy()
+        self.loop = policy.new_event_loop()
+        policy.set_event_loop(self.loop)
+
+    def tearDown(self):
+        self.loop.close()
+
+    def test_run(self):
+        assert asyncio.get_event_loop() == self.loop
+        async def _test():
+            return 'success'
+        self.assertEqual(juju.loop.run(_test()), 'success')
+
+    def test_run_interrupt(self):
+        async def _test():
+            juju.loop.run._sigint = True
+        self.assertRaises(KeyboardInterrupt, juju.loop.run, _test())
+
+    def test_run_exception(self):
+        async def _test():
+            raise ValueError()
+        self.assertRaises(ValueError, juju.loop.run, _test())
diff --git a/tox.ini b/tox.ini
index 5babec0..b4fec3d 100644 (file)
--- a/tox.ini
+++ b/tox.ini
@@ -11,7 +11,9 @@ skipsdist=True
 usedevelop=True
 passenv =
     HOME
-commands = py.test -rsx
+commands = py.test -ra -s -x -n auto
 deps =
     pytest
+    pytest-asyncio
+    pytest-xdist
     mock