New N2VC interface + updated libjuju

This commit introduces the Python3 N2VC module, which acts as a standard
interface to the VCA.

The goal of this is to provide a common way for modules to interface
with the VCA.

- Updated libjuju from 0.6.1 to 0.7.3

Signed-off-by: Adam Israel <adam.israel@canonical.com>
Change-Id: Ide70fb5ae5797eb6486de24653dc09a23f9c009e
diff --git a/modules/libjuju/tests/integration/test_client.py b/modules/libjuju/tests/integration/test_client.py
index e4c9c92..240c471 100644
--- a/modules/libjuju/tests/integration/test_client.py
+++ b/modules/libjuju/tests/integration/test_client.py
@@ -1,7 +1,7 @@
-import pytest
-
 from juju.client import client
 
+import pytest
+
 from .. import base
 
 
@@ -9,7 +9,7 @@
 @pytest.mark.asyncio
 async def test_user_info(event_loop):
     async with base.CleanModel() as model:
-        controller_conn = await model.connection.controller()
+        controller_conn = await model.connection().controller()
 
         um = client.UserManagerFacade.from_connection(controller_conn)
         result = await um.UserInfo(
diff --git a/modules/libjuju/tests/integration/test_connection.py b/modules/libjuju/tests/integration/test_connection.py
index 290203d..79ad9d0 100644
--- a/modules/libjuju/tests/integration/test_connection.py
+++ b/modules/libjuju/tests/integration/test_connection.py
@@ -1,28 +1,19 @@
 import asyncio
+
+from juju.client import client
+from juju.client.connection import Connection
+
 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()
-
+    async with base.CleanModel() as model:
+        conn = model.connection()
         assert conn.monitor.status == 'connected'
         await conn.close()
 
@@ -33,15 +24,17 @@
 @pytest.mark.asyncio
 async def test_monitor_catches_error(event_loop):
 
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
+    async with base.CleanModel() as model:
+        conn = model.connection()
 
         assert conn.monitor.status == 'connected'
-        await conn.ws.close()
-
-        assert conn.monitor.status == 'error'
-
-        await conn.close()
+        try:
+            async with conn.monitor.reconnecting:
+                await conn.ws.close()
+                await asyncio.sleep(1)
+                assert conn.monitor.status == 'error'
+        finally:
+            await conn.close()
 
 
 @base.bootstrapped
@@ -55,7 +48,7 @@
             channel='stable',
         )
 
-        c = client.ClientFacade.from_connection(model.connection)
+        c = client.ClientFacade.from_connection(model.connection())
 
         await c.FullStatus(None)
 
@@ -64,15 +57,8 @@
 @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)
+        kwargs = model.connection().connect_params()
+        conn = await Connection.connect(**kwargs)
         try:
             await asyncio.sleep(0.1)
             assert conn.is_open
diff --git a/modules/libjuju/tests/integration/test_controller.py b/modules/libjuju/tests/integration/test_controller.py
index d559313..9c6f7ac 100644
--- a/modules/libjuju/tests/integration/test_controller.py
+++ b/modules/libjuju/tests/integration/test_controller.py
@@ -2,10 +2,13 @@
 import pytest
 import uuid
 
-from .. import base
-from juju.controller import Controller
+from juju.client.connection import Connection
 from juju.errors import JujuAPIError
 
+import pytest
+
+from .. import base
+
 
 @base.bootstrapped
 @pytest.mark.asyncio
@@ -57,14 +60,18 @@
         username = 'test-password{}'.format(uuid.uuid4())
         user = await controller.add_user(username)
         await user.set_password('password')
+        # Check that we can connect with the new password.
+        new_connection = None
         try:
-            new_controller = Controller()
-            await new_controller.connect(
-                controller.connection.endpoint, username, 'password')
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
         except JujuAPIError:
             raise AssertionError('Unable to connect with new password')
         finally:
-            await new_controller.disconnect()
+            if new_connection:
+                await new_connection.close()
 
 
 @base.bootstrapped
@@ -77,10 +84,10 @@
         assert user.access == 'superuser'
         fresh = await controller.get_user(username)  # fetch fresh copy
         assert fresh.access == 'superuser'
-        await user.grant('login')
-        assert user.access == 'login'
+        await user.grant('login')  # already has 'superuser', so no-op
+        assert user.access == 'superuser'
         fresh = await controller.get_user(username)  # fetch fresh copy
-        assert fresh.access == 'login'
+        assert fresh.access == 'superuser'
         await user.revoke()
         assert user.access is ''
         fresh = await controller.get_user(username)  # fetch fresh copy
@@ -120,6 +127,11 @@
             await controller.destroy_model(model_name)
 
 
+async def _wait_for_model(controller, model_name):
+    while model_name not in await controller.list_models():
+        await asyncio.sleep(0.5, loop=controller.loop)
+
+
 async def _wait_for_model_gone(controller, model_name):
     while model_name in await controller.list_models():
         await asyncio.sleep(0.5, loop=controller.loop)
@@ -132,6 +144,9 @@
         model_name = 'test-{}'.format(uuid.uuid4())
         model = await controller.add_model(model_name)
         await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
         await controller.destroy_model(model_name)
         await asyncio.wait_for(_wait_for_model_gone(controller,
                                                     model_name),
@@ -146,6 +161,9 @@
         model = await controller.add_model(model_name)
         model_uuid = model.info.uuid
         await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
         await controller.destroy_model(model_uuid)
         await asyncio.wait_for(_wait_for_model_gone(controller,
                                                     model_name),
diff --git a/modules/libjuju/tests/integration/test_errors.py b/modules/libjuju/tests/integration/test_errors.py
index 06b3826..b10dd06 100644
--- a/modules/libjuju/tests/integration/test_errors.py
+++ b/modules/libjuju/tests/integration/test_errors.py
@@ -40,7 +40,7 @@
     from juju.client import client
 
     async with base.CleanModel() as model:
-        ann_facade = client.AnnotationsFacade.from_connection(model.connection)
+        ann_facade = client.AnnotationsFacade.from_connection(model.connection())
 
         ann = client.EntityAnnotations(
             entity='badtag',
@@ -58,11 +58,11 @@
     looking at a single result coming back.
 
     '''
-    from juju.errors import JujuError    
+    from juju.errors import JujuError
     from juju.client import client
 
     async with base.CleanModel() as model:
-        app_facade = client.ApplicationFacade.from_connection(model.connection)
+        app_facade = client.ApplicationFacade.from_connection(model.connection())
 
         with pytest.raises(JujuError):
             return await app_facade.GetCharmURL('foo')
diff --git a/modules/libjuju/tests/integration/test_machine.py b/modules/libjuju/tests/integration/test_machine.py
index cabf46d..8957ae1 100644
--- a/modules/libjuju/tests/integration/test_machine.py
+++ b/modules/libjuju/tests/integration/test_machine.py
@@ -1,8 +1,8 @@
 import asyncio
-import pytest
-
 from tempfile import NamedTemporaryFile
 
+import pytest
+
 from .. import base
 
 
@@ -42,6 +42,11 @@
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
     async with base.CleanModel() as model:
         await model.add_machine()
         await asyncio.wait_for(
diff --git a/modules/libjuju/tests/integration/test_model.py b/modules/libjuju/tests/integration/test_model.py
index 041f75a..ba2da92 100644
--- a/modules/libjuju/tests/integration/test_model.py
+++ b/modules/libjuju/tests/integration/test_model.py
@@ -1,11 +1,16 @@
 import asyncio
+import mock
 from concurrent.futures import ThreadPoolExecutor
 from pathlib import Path
+
+from juju.client.client import ConfigValue, ApplicationFacade
+from juju.model import Model, ModelObserver
+from juju.utils import block_until, run_with_interrupt
+
 import pytest
 
 from .. import base
-from juju.model import Model
-from juju.client.client import ConfigValue
+
 
 MB = 1
 GB = 1024
@@ -18,16 +23,30 @@
     from pathlib import Path
     tests_dir = Path(__file__).absolute().parent.parent
     bundle_path = tests_dir / 'bundle'
+    mini_bundle_file_path = bundle_path / 'mini-bundle.yaml'
 
     async with base.CleanModel() as model:
         await model.deploy(str(bundle_path))
+        await model.deploy(str(mini_bundle_file_path))
 
-        for app in ('wordpress', 'mysql'):
+        for app in ('wordpress', 'mysql', 'myapp'):
             assert app in model.applications
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
+async def test_deploy_local_charm(event_loop):
+    from pathlib import Path
+    tests_dir = Path(__file__).absolute().parent.parent
+    charm_path = tests_dir / 'charm'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(charm_path))
+        assert 'charm' 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')
@@ -43,7 +62,7 @@
         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')
+        rev = await model.deploy(charm + '-2', 'a3')
 
         assert [a.charm_url for a in (stable, edge, rev)] == [
             'cs:~johnsca/libjuju-test-1',
@@ -111,17 +130,44 @@
             # subordinates must be deployed without units
             num_units=0,
         )
-        my_relation = await model.add_relation(
-            'ubuntu',
-            'nrpe',
-        )
+
+        relation_added = asyncio.Event()
+        timeout = asyncio.Event()
+
+        class TestObserver(ModelObserver):
+            async def on_relation_add(self, delta, old, new, model):
+                if set(new.key.split()) == {'nrpe:general-info',
+                                            'ubuntu:juju-info'}:
+                    relation_added.set()
+                    event_loop.call_later(2, timeout.set)
+
+        model.add_observer(TestObserver())
+
+        real_app_facade = ApplicationFacade.from_connection(model.connection())
+        mock_app_facade = mock.MagicMock()
+
+        async def mock_AddRelation(*args):
+            # force response delay from AddRelation to test race condition
+            # (see https://github.com/juju/python-libjuju/issues/191)
+            result = await real_app_facade.AddRelation(*args)
+            await relation_added.wait()
+            return result
+
+        mock_app_facade.AddRelation = mock_AddRelation
+
+        with mock.patch.object(ApplicationFacade, 'from_connection',
+                               return_value=mock_app_facade):
+            my_relation = await run_with_interrupt(model.add_relation(
+                'ubuntu',
+                'nrpe',
+            ), timeout, event_loop)
 
         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)
+async def _deploy_in_loop(new_loop, model_name, jujudata):
+    new_model = Model(new_loop, jujudata=jujudata)
+    await new_model.connect(model_name)
     try:
         await new_model.deploy('cs:xenial/ubuntu')
         assert 'ubuntu' in new_model.applications
@@ -138,7 +184,7 @@
         with ThreadPoolExecutor(1) as executor:
             f = executor.submit(
                 new_loop.run_until_complete,
-                _deploy_in_loop(new_loop, model_name))
+                _deploy_in_loop(new_loop, model_name, model._connector.jujudata))
             f.result()
         await model._wait_for_new('application', 'ubuntu')
         assert 'ubuntu' in model.applications
@@ -155,7 +201,7 @@
             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'
@@ -174,7 +220,7 @@
             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'
@@ -206,9 +252,8 @@
 @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
+        await model.connection().ws.close()
+        await block_until(model.is_connected, timeout=3)
 
 
 @base.bootstrapped
diff --git a/modules/libjuju/tests/integration/test_unit.py b/modules/libjuju/tests/integration/test_unit.py
index 1604c31..8b2251c 100644
--- a/modules/libjuju/tests/integration/test_unit.py
+++ b/modules/libjuju/tests/integration/test_unit.py
@@ -1,8 +1,8 @@
 import asyncio
-import pytest
-
 from tempfile import NamedTemporaryFile
 
+import pytest
+
 from .. import base
 
 
@@ -52,6 +52,11 @@
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
     async with base.CleanModel() as model:
         app = await model.deploy('ubuntu')