Squashed 'modules/libjuju/' content from commit c50c361
git-subtree-dir: modules/libjuju
git-subtree-split: c50c361a8b9a3bbf1a33f5659e492b481f065cd2
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/base.py b/tests/base.py
new file mode 100644
index 0000000..e1ec452
--- /dev/null
+++ b/tests/base.py
@@ -0,0 +1,67 @@
+import mock
+import subprocess
+import uuid
+
+import pytest
+
+from juju.controller import Controller
+from juju.client.connection import JujuData
+
+
+def is_bootstrapped():
+ result = subprocess.run(['juju', 'switch'], stdout=subprocess.PIPE)
+ return (
+ result.returncode == 0 and
+ len(result.stdout.decode().strip()) > 0)
+
+bootstrapped = pytest.mark.skipif(
+ not is_bootstrapped(),
+ reason='bootstrapped Juju environment required')
+
+
+class CleanController():
+ def __init__(self):
+ self.controller = None
+
+ async def __aenter__(self):
+ self.controller = Controller()
+ await self.controller.connect_current()
+ return self.controller
+
+ async def __aexit__(self, exc_type, exc, tb):
+ await self.controller.disconnect()
+
+
+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)
+
+ # save the model UUID in case test closes model
+ self.model_uuid = self.model.info.uuid
+
+ # Ensure that we connect to the new model by default. This also
+ # prevents failures if test was started with no current model.
+ self._patch_cm = mock.patch.object(JujuData, 'current_model',
+ return_value=model_name)
+ self._patch_cm.start()
+
+ return self.model
+
+ async def __aexit__(self, exc_type, exc, tb):
+ self._patch_cm.stop()
+ await self.model.disconnect()
+ await self.controller.destroy_model(self.model_uuid)
+ await self.controller.disconnect()
+
+
+class AsyncMock(mock.MagicMock):
+ async def __call__(self, *args, **kwargs):
+ return super().__call__(*args, **kwargs)
diff --git a/tests/bundle/bundle.yaml b/tests/bundle/bundle.yaml
new file mode 100644
index 0000000..19a45ec
--- /dev/null
+++ b/tests/bundle/bundle.yaml
@@ -0,0 +1,28 @@
+series: xenial
+services:
+ wordpress:
+ charm: "cs:trusty/wordpress-2"
+ num_units: 1
+ annotations:
+ "gui-x": "339.5"
+ "gui-y": "-171"
+ to:
+ - "0"
+ mysql:
+ charm: "cs:trusty/mysql-26"
+ num_units: 1
+ annotations:
+ "gui-x": "79.5"
+ "gui-y": "-142"
+ to:
+ - "1"
+relations:
+ - - "wordpress:db"
+ - "mysql:db"
+machines:
+ "0":
+ series: trusty
+ constraints: "arch=amd64 cores=1 cpu-power=100 mem=1740 root-disk=8192"
+ "1":
+ series: trusty
+ constraints: "arch=amd64 cores=1 cpu-power=100 mem=1740 root-disk=8192"
diff --git a/tests/charm/metadata.yaml b/tests/charm/metadata.yaml
new file mode 100644
index 0000000..74eab3d
--- /dev/null
+++ b/tests/charm/metadata.yaml
@@ -0,0 +1,5 @@
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/integration/__init__.py
diff --git a/tests/integration/bundle/bundle.yaml b/tests/integration/bundle/bundle.yaml
new file mode 100644
index 0000000..d0245c5
--- /dev/null
+++ b/tests/integration/bundle/bundle.yaml
@@ -0,0 +1,12 @@
+series: xenial
+services:
+ ghost:
+ charm: "cs:ghost-19"
+ num_units: 1
+ mysql:
+ charm: "cs:trusty/mysql-57"
+ num_units: 1
+ test:
+ charm: "./tests/integration/charm"
+relations:
+ - ["ghost", "mysql"]
diff --git a/tests/integration/charm/metadata.yaml b/tests/integration/charm/metadata.yaml
new file mode 100644
index 0000000..74eab3d
--- /dev/null
+++ b/tests/integration/charm/metadata.yaml
@@ -0,0 +1,5 @@
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py
new file mode 100644
index 0000000..1a4fcaa
--- /dev/null
+++ b/tests/integration/test_application.py
@@ -0,0 +1,94 @@
+import pytest
+
+from .. import base
+
+MB = 1
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_action(event_loop):
+ async with base.CleanModel() as model:
+ ubuntu_app = await model.deploy(
+ 'mysql',
+ application_name='mysql',
+ series='trusty',
+ channel='stable',
+ config={
+ 'tuning-level': 'safest',
+ },
+ constraints={
+ 'mem': 256 * MB,
+ },
+ )
+
+ # update and check app config
+ await ubuntu_app.set_config({'tuning-level': 'fast'})
+ config = await ubuntu_app.get_config()
+ assert config['tuning-level']['value'] == 'fast'
+
+ # update and check app constraints
+ await ubuntu_app.set_constraints({'mem': 512 * MB})
+ constraints = await ubuntu_app.get_constraints()
+ assert constraints['mem'] == 512 * MB
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_units(event_loop):
+ from juju.unit import Unit
+
+ async with base.CleanModel() as model:
+ app = await model.deploy(
+ 'ubuntu-0',
+ application_name='ubuntu',
+ series='trusty',
+ channel='stable',
+ )
+ units = await app.add_units(count=2)
+
+ assert len(units) == 2
+ for unit in units:
+ assert isinstance(unit, Unit)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm(event_loop):
+ async with base.CleanModel() as model:
+ app = await model.deploy('ubuntu-0')
+ assert app.data['charm-url'] == 'cs:ubuntu-0'
+ await app.upgrade_charm()
+ assert app.data['charm-url'].startswith('cs:ubuntu-')
+ assert app.data['charm-url'] != 'cs:ubuntu-0'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_channel(event_loop):
+ async with base.CleanModel() as model:
+ app = await model.deploy('ubuntu-0')
+ assert app.data['charm-url'] == 'cs:ubuntu-0'
+ await app.upgrade_charm(channel='stable')
+ assert app.data['charm-url'].startswith('cs:ubuntu-')
+ assert app.data['charm-url'] != 'cs:ubuntu-0'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_revision(event_loop):
+ async with base.CleanModel() as model:
+ app = await model.deploy('ubuntu-0')
+ assert app.data['charm-url'] == 'cs:ubuntu-0'
+ await app.upgrade_charm(revision=8)
+ assert app.data['charm-url'] == 'cs:ubuntu-8'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_switch(event_loop):
+ async with base.CleanModel() as model:
+ app = await model.deploy('ubuntu-0')
+ assert app.data['charm-url'] == 'cs:ubuntu-0'
+ await app.upgrade_charm(switch='ubuntu-8')
+ assert app.data['charm-url'] == 'cs:ubuntu-8'
diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py
new file mode 100644
index 0000000..e4c9c92
--- /dev/null
+++ b/tests/integration/test_client.py
@@ -0,0 +1,21 @@
+import pytest
+
+from juju.client import client
+
+from .. import base
+
+
+@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.from_connection(controller_conn)
+ result = await um.UserInfo(
+ [client.Entity('user-admin')], True)
+ await controller_conn.close()
+
+ assert isinstance(result, client.UserInfoResults)
+ for r in result.results:
+ assert isinstance(r, client.UserInfoResult)
diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py
new file mode 100644
index 0000000..290203d
--- /dev/null
+++ b/tests/integration/test_connection.py
@@ -0,0 +1,83 @@
+import asyncio
+import pytest
+
+from juju.client.connection import Connection
+from juju.client import client
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_connect_current(event_loop):
+ async with base.CleanModel():
+ conn = await Connection.connect_current()
+
+ assert isinstance(conn, Connection)
+ await conn.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_monitor(event_loop):
+
+ async with base.CleanModel():
+ conn = await Connection.connect_current()
+
+ assert conn.monitor.status == 'connected'
+ await conn.close()
+
+ assert conn.monitor.status == 'disconnected'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_monitor_catches_error(event_loop):
+
+ async with base.CleanModel():
+ conn = await Connection.connect_current()
+
+ assert conn.monitor.status == 'connected'
+ await conn.ws.close()
+
+ assert conn.monitor.status == 'error'
+
+ await conn.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_full_status(event_loop):
+ async with base.CleanModel() as model:
+ await model.deploy(
+ 'ubuntu-0',
+ application_name='ubuntu',
+ series='trusty',
+ channel='stable',
+ )
+
+ c = client.ClientFacade.from_connection(model.connection)
+
+ await c.FullStatus(None)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_reconnect(event_loop):
+ async with base.CleanModel() as model:
+ conn = await Connection.connect(
+ model.connection.endpoint,
+ model.connection.uuid,
+ model.connection.username,
+ model.connection.password,
+ model.connection.cacert,
+ model.connection.macaroons,
+ model.connection.loop,
+ model.connection.max_frame_size)
+ try:
+ await asyncio.sleep(0.1)
+ assert conn.is_open
+ await conn.ws.close()
+ assert not conn.is_open
+ await model.block_until(lambda: conn.is_open, timeout=3)
+ finally:
+ await conn.close()
diff --git a/tests/integration/test_controller.py b/tests/integration/test_controller.py
new file mode 100644
index 0000000..f3840cc
--- /dev/null
+++ b/tests/integration/test_controller.py
@@ -0,0 +1,77 @@
+import pytest
+import uuid
+
+from .. import base
+from juju.controller import Controller
+from juju.errors import JujuAPIError
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_user(event_loop):
+ async with base.CleanController() as controller:
+ username = 'test{}'.format(uuid.uuid4())
+ await controller.add_user(username)
+ result = await controller.get_user(username)
+ res_ser = result.serialize()['results'][0].serialize()
+ assert res_ser['result'] is not None
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_disable_enable_user(event_loop):
+ async with base.CleanController() as controller:
+ username = 'test-disable{}'.format(uuid.uuid4())
+ await controller.add_user(username)
+ await controller.disable_user(username)
+ result = await controller.get_user(username)
+ res_ser = result.serialize()['results'][0].serialize()
+ assert res_ser['result'].serialize()['disabled'] is True
+ await controller.enable_user(username)
+ result = await controller.get_user(username)
+ res_ser = result.serialize()['results'][0].serialize()
+ assert res_ser['result'].serialize()['disabled'] is False
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_change_user_password(event_loop):
+ async with base.CleanController() as controller:
+ username = 'test-password{}'.format(uuid.uuid4())
+ await controller.add_user(username)
+ await controller.change_user_password(username, 'password')
+ try:
+ new_controller = Controller()
+ await new_controller.connect(
+ controller.connection.endpoint, username, 'password')
+ result = True
+ await new_controller.disconnect()
+ except JujuAPIError:
+ result = False
+ assert result is True
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_grant(event_loop):
+ async with base.CleanController() as controller:
+ username = 'test-grant{}'.format(uuid.uuid4())
+ await controller.add_user(username)
+ await controller.grant(username, 'superuser')
+ result = await controller.get_user(username)
+ result = result.serialize()['results'][0].serialize()['result']\
+ .serialize()
+ assert result['access'] == 'superuser'
+ await controller.grant(username, 'login')
+ result = await controller.get_user(username)
+ result = result.serialize()['results'][0].serialize()['result']\
+ .serialize()
+ assert result['access'] == 'login'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_get_models(event_loop):
+ async with base.CleanController() as controller:
+ result = await controller.get_models()
+ assert isinstance(result.serialize()['user-models'], list)
diff --git a/tests/integration/test_errors.py b/tests/integration/test_errors.py
new file mode 100644
index 0000000..06b3826
--- /dev/null
+++ b/tests/integration/test_errors.py
@@ -0,0 +1,68 @@
+import pytest
+
+from .. import base
+
+MB = 1
+GB = 1024
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_api_error(event_loop):
+ '''
+ Verify that we raise a JujuAPIError for responses with an error in
+ a top level key (for completely invalid requests).
+
+ '''
+ from juju.errors import JujuAPIError
+
+ async with base.CleanModel() as model:
+ with pytest.raises(JujuAPIError):
+ await model.add_machine(constraints={'mem': 'foo'})
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_error_in_results_list(event_loop):
+ '''
+ Replicate the code that caused
+ https://github.com/juju/python-libjuju/issues/67, and verify that
+ we get a JujuError instead of passing silently by the failure.
+
+ (We don't raise a JujuAPIError, because the request isn't
+ completely invalid -- it's just passing a tag that doesn't exist.)
+
+ This also verifies that we will raise a JujuError any time there
+ is an error in one of a list of results.
+
+ '''
+ from juju.errors import JujuError
+ from juju.client import client
+
+ async with base.CleanModel() as model:
+ ann_facade = client.AnnotationsFacade.from_connection(model.connection)
+
+ ann = client.EntityAnnotations(
+ entity='badtag',
+ annotations={'gui-x': '1', 'gui-y': '1'},
+ )
+ with pytest.raises(JujuError):
+ return await ann_facade.Set([ann])
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_error_in_result(event_loop):
+ '''
+ Verify that we raise a JujuError when appropraite when we are
+ looking at a single result coming back.
+
+ '''
+ from juju.errors import JujuError
+ from juju.client import client
+
+ async with base.CleanModel() as model:
+ app_facade = client.ApplicationFacade.from_connection(model.connection)
+
+ with pytest.raises(JujuError):
+ return await app_facade.GetCharmURL('foo')
diff --git a/tests/integration/test_machine.py b/tests/integration/test_machine.py
new file mode 100644
index 0000000..60de035
--- /dev/null
+++ b/tests/integration/test_machine.py
@@ -0,0 +1,62 @@
+import asyncio
+import pytest
+
+from tempfile import NamedTemporaryFile
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_status(event_loop):
+ async with base.CleanModel() as model:
+ await model.deploy(
+ 'ubuntu-0',
+ application_name='ubuntu',
+ series='trusty',
+ channel='stable',
+ )
+
+ await asyncio.wait_for(
+ model.block_until(lambda: len(model.machines)),
+ timeout=240)
+ machine = model.machines['0']
+
+ assert machine.status in ('allocating', 'pending')
+ assert machine.agent_status == 'pending'
+ assert not machine.agent_version
+
+ await asyncio.wait_for(
+ model.block_until(lambda: (machine.status == 'running' and
+ machine.agent_status == 'started')),
+ timeout=480)
+
+ assert machine.status == 'running'
+ # there is some inconsistency in the message case between providers
+ assert machine.status_message.lower() == 'running'
+ assert machine.agent_status == 'started'
+ assert machine.agent_version.major >= 2
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_scp(event_loop):
+ async with base.CleanModel() as model:
+ await model.add_machine()
+ await asyncio.wait_for(
+ model.block_until(lambda: model.machines),
+ timeout=240)
+ machine = model.machines['0']
+ await asyncio.wait_for(
+ model.block_until(lambda: (machine.status == 'running' and
+ machine.agent_status == 'started')),
+ timeout=480)
+
+ with NamedTemporaryFile() as f:
+ f.write(b'testcontents')
+ f.flush()
+ await machine.scp_to(f.name, 'testfile')
+
+ with NamedTemporaryFile() as f:
+ await machine.scp_from('testfile', f.name)
+ assert f.read() == b'testcontents'
diff --git a/tests/integration/test_model.py b/tests/integration/test_model.py
new file mode 100644
index 0000000..8506786
--- /dev/null
+++ b/tests/integration/test_model.py
@@ -0,0 +1,249 @@
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+import pytest
+
+from .. import base
+from juju.model import Model
+from juju.client.client import ConfigValue
+
+MB = 1
+GB = 1024
+SSH_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsYMJGNGG74HAJha3n2CFmWYsOOaORnJK6VqNy86pj0MIpvRXBzFzVy09uPQ66GOQhTEoJHEqE77VMui7+62AcMXT+GG7cFHcnU8XVQsGM6UirCcNyWNysfiEMoAdZScJf/GvoY87tMEszhZIUV37z8PUBx6twIqMdr31W1J0IaPa+sV6FEDadeLaNTvancDcHK1zuKsL39jzAg7+LYjKJfEfrsQP+lj/EQcjtKqlhVS5kzsJVfx8ZEd0xhW5G7N6bCdKNalS8mKCMaBXJpijNQ82AiyqCIDCRrre2To0/i7pTjRiL0U9f9mV3S4NJaQaokR050w/ZLySFf6F7joJT mathijs@Qrama-Mathijs' # noqa
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_bundle(event_loop):
+ from pathlib import Path
+ tests_dir = Path(__file__).absolute().parent.parent
+ bundle_path = tests_dir / 'bundle'
+
+ async with base.CleanModel() as model:
+ await model.deploy(str(bundle_path))
+
+ for app in ('wordpress', 'mysql'):
+ assert app in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_bundle(event_loop):
+ async with base.CleanModel() as model:
+ await model.deploy('bundle/wiki-simple')
+
+ for app in ('wiki', 'mysql'):
+ assert app in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_channels_revs(event_loop):
+ async with base.CleanModel() as model:
+ charm = 'cs:~johnsca/libjuju-test'
+ stable = await model.deploy(charm, 'a1')
+ edge = await model.deploy(charm, 'a2', channel='edge')
+ rev = await model.deploy(charm+'-2', 'a3')
+
+ assert [a.charm_url for a in (stable, edge, rev)] == [
+ 'cs:~johnsca/libjuju-test-1',
+ 'cs:~johnsca/libjuju-test-2',
+ 'cs:~johnsca/libjuju-test-2',
+ ]
+
+
+@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
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_relate(event_loop):
+ from juju.relation import Relation
+
+ async with base.CleanModel() as model:
+ await model.deploy(
+ 'ubuntu',
+ application_name='ubuntu',
+ series='trusty',
+ channel='stable',
+ )
+ await model.deploy(
+ 'nrpe',
+ application_name='nrpe',
+ series='trusty',
+ channel='stable',
+ # subordinates must be deployed without units
+ num_units=0,
+ )
+ my_relation = await model.add_relation(
+ 'ubuntu',
+ 'nrpe',
+ )
+
+ assert isinstance(my_relation, Relation)
+
+
+async def _deploy_in_loop(new_loop, model_name):
+ new_model = Model(new_loop)
+ await new_model.connect_model(model_name)
+ try:
+ await new_model.deploy('cs:xenial/ubuntu')
+ assert 'ubuntu' in new_model.applications
+ finally:
+ await new_model.disconnect()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_explicit_loop(event_loop):
+ async with base.CleanModel() as model:
+ model_name = model.info.name
+ new_loop = asyncio.new_event_loop()
+ new_loop.run_until_complete(
+ _deploy_in_loop(new_loop, model_name))
+ await model._wait_for_new('application', 'ubuntu')
+ assert 'ubuntu' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_explicit_loop_threaded(event_loop):
+ async with base.CleanModel() as model:
+ model_name = model.info.name
+ new_loop = asyncio.new_event_loop()
+ with ThreadPoolExecutor(1) as executor:
+ f = executor.submit(
+ new_loop.run_until_complete,
+ _deploy_in_loop(new_loop, model_name))
+ f.result()
+ await model._wait_for_new('application', 'ubuntu')
+ assert 'ubuntu' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_charm(event_loop):
+ async with base.CleanModel() as model:
+ ghost = await model.deploy('cs:ghost-19')
+ assert 'ghost' in model.applications
+ terminal_statuses = ('active', 'error', 'blocked')
+ await model.block_until(
+ lambda: (
+ len(ghost.units) > 0 and
+ ghost.units[0].workload_status in terminal_statuses)
+ )
+ # ghost will go in to blocked (or error, for older
+ # charm revs) if the resource is missing
+ assert ghost.units[0].workload_status == 'active'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_bundle(event_loop):
+ async with base.CleanModel() as model:
+ bundle = str(Path(__file__).parent / 'bundle')
+ await model.deploy(bundle)
+ assert 'ghost' in model.applications
+ ghost = model.applications['ghost']
+ terminal_statuses = ('active', 'error', 'blocked')
+ await model.block_until(
+ lambda: (
+ len(ghost.units) > 0 and
+ ghost.units[0].workload_status in terminal_statuses)
+ )
+ # ghost will go in to blocked (or error, for older
+ # charm revs) if the resource is missing
+ assert ghost.units[0].workload_status == 'active'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_ssh_key(event_loop):
+ async with base.CleanModel() as model:
+ await model.add_ssh_key('admin', SSH_KEY)
+ result = await model.get_ssh_key(True)
+ result = result.serialize()['results'][0].serialize()['result']
+ assert SSH_KEY in result
+ await model.remove_ssh_key('admin', SSH_KEY)
+ result = await model.get_ssh_key(True)
+ result = result.serialize()['results'][0].serialize()['result']
+ assert result is None
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_get_machines(event_loop):
+ async with base.CleanModel() as model:
+ result = await model.get_machines()
+ assert isinstance(result, list)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_watcher_reconnect(event_loop):
+ async with base.CleanModel() as model:
+ await model.connection.ws.close()
+ await asyncio.sleep(0.1)
+ assert model.connection.is_open
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_config(event_loop):
+ async with base.CleanModel() as model:
+ await model.set_config({
+ 'extra-info': 'booyah',
+ 'test-mode': ConfigValue(value=True),
+ })
+ result = await model.get_config()
+ assert 'extra-info' in result
+ assert result['extra-info'].source == 'model'
+ assert result['extra-info'].value == 'booyah'
+
+# @base.bootstrapped
+# @pytest.mark.asyncio
+# async def test_grant(event_loop)
+# async with base.CleanController() as controller:
+# await controller.add_user('test-model-grant')
+# await controller.grant('test-model-grant', 'superuser')
+# async with base.CleanModel() as model:
+# await model.grant('test-model-grant', 'admin')
+# assert model.get_user('test-model-grant')['access'] == 'admin'
+# await model.grant('test-model-grant', 'login')
+# assert model.get_user('test-model-grant')['access'] == 'login'
diff --git a/tests/integration/test_unit.py b/tests/integration/test_unit.py
new file mode 100644
index 0000000..1604c31
--- /dev/null
+++ b/tests/integration/test_unit.py
@@ -0,0 +1,78 @@
+import asyncio
+import pytest
+
+from tempfile import NamedTemporaryFile
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_run(event_loop):
+ from juju.action import Action
+
+ async with base.CleanModel() as model:
+ app = await model.deploy(
+ 'ubuntu-0',
+ application_name='ubuntu',
+ series='trusty',
+ channel='stable',
+ )
+
+ for unit in app.units:
+ action = await unit.run('unit-get public-address')
+ assert isinstance(action, Action)
+ assert 'Stdout' in action.results
+ break
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_run_action(event_loop):
+ async def run_action(unit):
+ # unit.run() returns a juju.action.Action instance
+ action = await unit.run_action('add-repo', repo='myrepo')
+ # wait for the action to complete
+ return await action.wait()
+
+ async with base.CleanModel() as model:
+ app = await model.deploy(
+ 'git',
+ application_name='git',
+ series='trusty',
+ channel='stable',
+ )
+
+ for unit in app.units:
+ action = await run_action(unit)
+ assert action.results == {'dir': '/var/git/myrepo.git'}
+ break
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_scp(event_loop):
+ async with base.CleanModel() as model:
+ app = await model.deploy('ubuntu')
+
+ await asyncio.wait_for(
+ model.block_until(lambda: app.units),
+ timeout=60)
+ unit = app.units[0]
+ await asyncio.wait_for(
+ model.block_until(lambda: unit.machine),
+ timeout=60)
+ machine = unit.machine
+ await asyncio.wait_for(
+ model.block_until(lambda: (machine.status == 'running' and
+ machine.agent_status == 'started')),
+ timeout=480)
+
+ with NamedTemporaryFile() as f:
+ f.write(b'testcontents')
+ f.flush()
+ await unit.scp_to(f.name, 'testfile')
+
+ with NamedTemporaryFile() as f:
+ await unit.scp_from('testfile', f.name)
+ assert f.read() == b'testcontents'
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/unit/__init__.py
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
new file mode 100644
index 0000000..7828cf3
--- /dev/null
+++ b/tests/unit/test_client.py
@@ -0,0 +1,25 @@
+"""
+Tests for generated client code
+
+"""
+
+import mock
+import pytest
+
+
+from juju.client import client
+
+
+
+def test_basics():
+ assert client.CLIENTS
+ for i in range(1,5): # Assert versions 1-4 in client dict
+ assert str(i) in client.CLIENTS
+
+
+def test_from_connection():
+ connection = mock.Mock()
+ connection.facades = {"Action": 2}
+ action_facade = client.ActionFacade.from_connection(connection)
+
+
diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py
new file mode 100644
index 0000000..f69b8d6
--- /dev/null
+++ b/tests/unit/test_connection.py
@@ -0,0 +1,54 @@
+import asyncio
+import json
+import mock
+import pytest
+from collections import deque
+
+from websockets.exceptions import ConnectionClosed
+
+from .. import base
+from juju.client.connection import Connection
+
+
+class WebsocketMock:
+ def __init__(self, responses):
+ super().__init__()
+ self.responses = deque(responses)
+ self.open = True
+
+ async def send(self, message):
+ pass
+
+ async def recv(self):
+ if not self.responses:
+ await asyncio.sleep(1) # delay to give test time to finish
+ raise ConnectionClosed(0, 'ran out of responses')
+ return json.dumps(self.responses.popleft())
+
+ async def close(self):
+ self.open = False
+
+
+@pytest.mark.asyncio
+async def test_out_of_order(event_loop):
+ con = Connection(*[None]*4)
+ ws = WebsocketMock([
+ {'request-id': 1},
+ {'request-id': 3},
+ {'request-id': 2},
+ ])
+ expected_responses = [
+ {'request-id': 1},
+ {'request-id': 2},
+ {'request-id': 3},
+ ]
+ con._get_sll = mock.MagicMock()
+ try:
+ with mock.patch('websockets.connect', base.AsyncMock(return_value=ws)):
+ await con.open()
+ actual_responses = []
+ for i in range(3):
+ actual_responses.append(await con.rpc({'version': 1}))
+ assert actual_responses == expected_responses
+ finally:
+ await con.close()
diff --git a/tests/unit/test_constraints.py b/tests/unit/test_constraints.py
new file mode 100644
index 0000000..cb9d773
--- /dev/null
+++ b/tests/unit/test_constraints.py
@@ -0,0 +1,47 @@
+#
+# Test our constraints parser
+#
+
+import unittest
+
+from juju import constraints
+
+class TestConstraints(unittest.TestCase):
+
+ def test_mem_regex(self):
+ m = constraints.MEM
+ self.assertTrue(m.match("10G"))
+ self.assertTrue(m.match("1G"))
+ self.assertFalse(m.match("1Gb"))
+ self.assertFalse(m.match("a1G"))
+ self.assertFalse(m.match("1000"))
+
+ def test_normalize_key(self):
+ _ = constraints.normalize_key
+
+ self.assertEqual(_("test-key"), "test_key")
+ self.assertEqual(_("test-key "), "test_key")
+ self.assertEqual(_(" test-key"), "test_key")
+ self.assertEqual(_("TestKey"), "test_key")
+ self.assertEqual(_("testKey"), "test_key")
+
+ def test_normalize_val(self):
+ _ = constraints.normalize_value
+
+ self.assertEqual(_("10G"), 10 * 1024)
+ self.assertEqual(_("10M"), 10)
+ self.assertEqual(_("10"), 10)
+ self.assertEqual(_("foo,bar"), ["foo", "bar"])
+
+ def test_parse_constraints(self):
+ _ = constraints.parse
+
+ self.assertEqual(
+ _("mem=10G"),
+ {"mem": 10 * 1024}
+ )
+
+ self.assertEqual(
+ _("mem=10G foo=bar,baz"),
+ {"mem": 10 * 1024, "foo": ["bar", "baz"]}
+ )
diff --git a/tests/unit/test_loop.py b/tests/unit/test_loop.py
new file mode 100644
index 0000000..f12368e
--- /dev/null
+++ b/tests/unit/test_loop.py
@@ -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/tests/unit/test_model.py b/tests/unit/test_model.py
new file mode 100644
index 0000000..222d881
--- /dev/null
+++ b/tests/unit/test_model.py
@@ -0,0 +1,155 @@
+import unittest
+
+import mock
+import asynctest
+
+
+def _make_delta(entity, type_, data=None):
+ from juju.client.client import Delta
+ from juju.delta import get_entity_delta
+
+ delta = Delta([entity, type_, data])
+ return get_entity_delta(delta)
+
+
+class TestObserver(unittest.TestCase):
+ def _make_observer(self, *args):
+ from juju.model import _Observer
+ return _Observer(*args)
+
+ def test_cares_about_id(self):
+ id_ = 'foo'
+
+ o = self._make_observer(
+ None, None, None, id_, None)
+
+ delta = _make_delta(
+ 'application', 'change', dict(name=id_))
+
+ self.assertTrue(o.cares_about(delta))
+
+ def test_cares_about_type(self):
+ type_ = 'application'
+
+ o = self._make_observer(
+ None, type_, None, None, None)
+
+ delta = _make_delta(
+ type_, 'change', dict(name='foo'))
+
+ self.assertTrue(o.cares_about(delta))
+
+ def test_cares_about_action(self):
+ action = 'change'
+
+ o = self._make_observer(
+ None, None, action, None, None)
+
+ delta = _make_delta(
+ 'application', action, dict(name='foo'))
+
+ self.assertTrue(o.cares_about(delta))
+
+ def test_cares_about_predicate(self):
+ def predicate(delta):
+ return delta.data.get('fizz') == 'bang'
+
+ o = self._make_observer(
+ None, None, None, None, predicate)
+
+ delta = _make_delta(
+ 'application', 'change', dict(fizz='bang'))
+
+ self.assertTrue(o.cares_about(delta))
+
+
+class TestModelState(unittest.TestCase):
+ def test_apply_delta(self):
+ from juju.model import Model
+ from juju.application import Application
+
+ loop = mock.MagicMock()
+ model = Model(loop=loop)
+ delta = _make_delta('application', 'add', dict(name='foo'))
+
+ # test add
+ prev, new = model.state.apply_delta(delta)
+ self.assertEqual(
+ len(model.state.state[delta.entity][delta.get_id()]), 1)
+ self.assertIsNone(prev)
+ self.assertIsInstance(new, Application)
+
+ # test remove
+ delta.type = 'remove'
+ prev, new = model.state.apply_delta(delta)
+ # length of the entity history deque is now 3:
+ # - 1 for the first delta
+ # - 1 for the second delta
+ # - 1 for the None sentinel appended after the 'remove'
+ self.assertEqual(
+ len(model.state.state[delta.entity][delta.get_id()]), 3)
+ self.assertIsInstance(new, Application)
+ # new object is falsy because its data is None
+ self.assertFalse(new)
+ self.assertIsInstance(prev, Application)
+ self.assertTrue(prev)
+
+
+def test_get_series():
+ from juju.model import Model
+ model = Model()
+ entity = {
+ 'Meta': {
+ 'supported-series': {
+ 'SupportedSeries': [
+ 'xenial',
+ 'trusty',
+ ],
+ },
+ },
+ }
+ assert model._get_series('cs:trusty/ubuntu', entity) == 'trusty'
+ assert model._get_series('xenial/ubuntu', entity) == 'xenial'
+ assert model._get_series('~foo/xenial/ubuntu', entity) == 'xenial'
+ assert model._get_series('~foo/ubuntu', entity) == 'xenial'
+ assert model._get_series('ubuntu', entity) == 'xenial'
+ assert model._get_series('cs:ubuntu', entity) == 'xenial'
+
+
+class TestContextManager(asynctest.TestCase):
+ @asynctest.patch('juju.model.Model.disconnect')
+ @asynctest.patch('juju.model.Model.connect_current')
+ async def test_normal_use(self, mock_connect, mock_disconnect):
+ from juju.model import Model
+
+ async with Model() as model:
+ self.assertTrue(isinstance(model, Model))
+
+ self.assertTrue(mock_connect.called)
+ self.assertTrue(mock_disconnect.called)
+
+ @asynctest.patch('juju.model.Model.disconnect')
+ @asynctest.patch('juju.model.Model.connect_current')
+ async def test_exception(self, mock_connect, mock_disconnect):
+ from juju.model import Model
+
+ class SomeException(Exception):
+ pass
+
+ with self.assertRaises(SomeException):
+ async with Model():
+ raise SomeException()
+
+ self.assertTrue(mock_connect.called)
+ self.assertTrue(mock_disconnect.called)
+
+ @asynctest.patch('juju.client.connection.JujuData.current_controller')
+ async def test_no_current_connection(self, mock_current_controller):
+ from juju.model import Model
+ from juju.errors import JujuConnectionError
+
+ mock_current_controller.return_value = ""
+
+ with self.assertRaises(JujuConnectionError):
+ async with Model():
+ pass
diff --git a/tests/unit/test_overrides.py b/tests/unit/test_overrides.py
new file mode 100644
index 0000000..6485408
--- /dev/null
+++ b/tests/unit/test_overrides.py
@@ -0,0 +1,76 @@
+import pytest
+
+from juju.client.overrides import Number, Binary # noqa
+
+
+# test cases ported from:
+# https://github.com/juju/version/blob/master/version_test.go
+@pytest.mark.parametrize("input,expected", (
+ (None, Number(major=0, minor=0, patch=0, tag='', build=0)),
+ (Number(major=1, minor=0, patch=0), Number(major=1, minor=0, patch=0)),
+ ({'major': 1, 'minor': 0, 'patch': 0}, Number(major=1, minor=0, patch=0)),
+ ("0.0.1", Number(major=0, minor=0, patch=1)),
+ ("0.0.2", Number(major=0, minor=0, patch=2)),
+ ("0.1.0", Number(major=0, minor=1, patch=0)),
+ ("0.2.3", Number(major=0, minor=2, patch=3)),
+ ("1.0.0", Number(major=1, minor=0, patch=0)),
+ ("10.234.3456", Number(major=10, minor=234, patch=3456)),
+ ("10.234.3456.1", Number(major=10, minor=234, patch=3456, build=1)),
+ ("10.234.3456.64", Number(major=10, minor=234, patch=3456, build=64)),
+ ("10.235.3456", Number(major=10, minor=235, patch=3456)),
+ ("1.21-alpha1", Number(major=1, minor=21, patch=1, tag="alpha")),
+ ("1.21-alpha1.1", Number(major=1, minor=21, patch=1, tag="alpha",
+ build=1)),
+ ("1.21-alpha10", Number(major=1, minor=21, patch=10, tag="alpha")),
+ ("1.21.0", Number(major=1, minor=21)),
+ ("1234567890.2.1", TypeError),
+ ("0.2..1", TypeError),
+ ("1.21.alpha1", TypeError),
+ ("1.21-alpha", TypeError),
+ ("1.21-alpha1beta", TypeError),
+ ("1.21-alpha-dev", TypeError),
+ ("1.21-alpha_dev3", TypeError),
+ ("1.21-alpha123dev3", TypeError),
+))
+def test_number(input, expected):
+ if expected is TypeError:
+ with pytest.raises(expected):
+ Number.from_json(input)
+ else:
+ result = Number.from_json(input)
+ assert result == expected
+ if isinstance(input, str):
+ assert result.to_json() == input
+
+
+# test cases ported from:
+# https://github.com/juju/version/blob/master/version_test.go
+@pytest.mark.parametrize("input,expected", (
+ (None, Binary(Number(), None, None)),
+ (Binary(Number(1), 'trusty', 'amd64'), Binary(Number(1),
+ 'trusty', 'amd64')),
+ ({'number': {'major': 1},
+ 'series': 'trusty',
+ 'arch': 'amd64'}, Binary(Number(1), 'trusty', 'amd64')),
+ ("1.2.3-trusty-amd64", Binary(Number(1, 2, 3, "", 0),
+ "trusty", "amd64")),
+ ("1.2.3.4-trusty-amd64", Binary(Number(1, 2, 3, "", 4),
+ "trusty", "amd64")),
+ ("1.2-alpha3-trusty-amd64", Binary(Number(1, 2, 3, "alpha", 0),
+ "trusty", "amd64")),
+ ("1.2-alpha3.4-trusty-amd64", Binary(Number(1, 2, 3, "alpha", 4),
+ "trusty", "amd64")),
+ ("1.2.3", TypeError),
+ ("1.2-beta1", TypeError),
+ ("1.2.3--amd64", TypeError),
+ ("1.2.3-trusty-", TypeError),
+))
+def test_binary(input, expected):
+ if expected is TypeError:
+ with pytest.raises(expected):
+ Binary.from_json(input)
+ else:
+ result = Binary.from_json(input)
+ assert result == expected
+ if isinstance(input, str):
+ assert result.to_json() == input
diff --git a/tests/unit/test_placement.py b/tests/unit/test_placement.py
new file mode 100644
index 0000000..a78a28d
--- /dev/null
+++ b/tests/unit/test_placement.py
@@ -0,0 +1,20 @@
+#
+# Test our placement helper
+#
+
+import unittest
+
+from juju import placement
+from juju.client import client
+
+class TestPlacement(unittest.TestCase):
+
+ def test_parse_both_specified(self):
+ res = placement.parse("foo:bar")
+ self.assertEqual(res[0].scope, "foo")
+ self.assertEqual(res[0].directive, "bar")
+
+ def test_parse_machine(self):
+ res = placement.parse("22")
+ self.assertEqual(res[0].scope, "#")
+ self.assertEqual(res[0].directive, "22")