Improved Primitive support and better testing
This changeset addresses several issues.
- Improve primitive support so the status and output of an executed
primitive can be retrieved
- Merge latest upstream libjuju (required for new primive features)
- New testing framework
This is the start of a new testing framework with the ability to
create and configure LXD containers with SSH, to use while testing proxy
charms.
- Add support for using ssh keys with proxy charms
See Feature 1429. This uses the per-proxy charm/unit ssh keypair
Signed-off-by: Adam Israel <adam.israel@canonical.com>
diff --git a/modules/libjuju/tests/integration/test_application.py b/modules/libjuju/tests/integration/test_application.py
index 7b780da..b705832 100644
--- a/modules/libjuju/tests/integration/test_application.py
+++ b/modules/libjuju/tests/integration/test_application.py
@@ -1,4 +1,5 @@
import asyncio
+
import pytest
from .. import base
@@ -11,9 +12,9 @@
async def test_action(event_loop):
async with base.CleanModel() as model:
ubuntu_app = await model.deploy(
- 'mysql',
+ 'percona-cluster',
application_name='mysql',
- series='trusty',
+ series='xenial',
channel='stable',
config={
'tuning-level': 'safest',
@@ -28,11 +29,20 @@
config = await ubuntu_app.get_config()
assert config['tuning-level']['value'] == 'fast'
+ # Restore config back to default
+ await ubuntu_app.reset_config(['tuning-level'])
+ config = await ubuntu_app.get_config()
+ assert config['tuning-level']['value'] == 'safest'
+
# update and check app constraints
await ubuntu_app.set_constraints({'mem': 512 * MB})
constraints = await ubuntu_app.get_constraints()
assert constraints['mem'] == 512 * MB
+ # check action definitions
+ actions = await ubuntu_app.get_actions()
+ assert 'backup' in actions.keys()
+
@base.bootstrapped
@pytest.mark.asyncio
diff --git a/modules/libjuju/tests/integration/test_controller.py b/modules/libjuju/tests/integration/test_controller.py
index 9c6f7ac..93e2883 100644
--- a/modules/libjuju/tests/integration/test_controller.py
+++ b/modules/libjuju/tests/integration/test_controller.py
@@ -1,8 +1,10 @@
import asyncio
-import pytest
+import subprocess
import uuid
from juju.client.connection import Connection
+from juju.client.jujudata import FileJujuData
+from juju.controller import Controller
from juju.errors import JujuAPIError
import pytest
@@ -168,3 +170,31 @@
await asyncio.wait_for(_wait_for_model_gone(controller,
model_name),
timeout=60)
+
+
+# this test must be run serially because it modifies the login password
+@pytest.mark.serial
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_macaroon_auth(event_loop):
+ jujudata = FileJujuData()
+ account = jujudata.accounts()[jujudata.current_controller()]
+ with base.patch_file('~/.local/share/juju/accounts.yaml'):
+ if 'password' in account:
+ # force macaroon auth by "changing" password to current password
+ result = subprocess.run(
+ ['juju', 'change-user-password'],
+ input='{0}\n{0}\n'.format(account['password']),
+ universal_newlines=True,
+ stderr=subprocess.PIPE)
+ assert result.returncode == 0, ('Failed to change password: '
+ '{}'.format(result.stderr))
+ controller = Controller()
+ try:
+ await controller.connect()
+ assert controller.is_connected()
+ finally:
+ if controller.is_connected():
+ await controller.disconnect()
+ async with base.CleanModel():
+ pass # create and login to model works
diff --git a/modules/libjuju/tests/integration/test_macaroon_auth.py b/modules/libjuju/tests/integration/test_macaroon_auth.py
new file mode 100644
index 0000000..9911c41
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_macaroon_auth.py
@@ -0,0 +1,108 @@
+import logging
+import os
+
+import macaroonbakery.bakery as bakery
+import macaroonbakery.httpbakery as httpbakery
+import macaroonbakery.httpbakery.agent as agent
+from juju.errors import JujuAPIError
+from juju.model import Model
+
+import pytest
+
+from .. import base
+
+log = logging.getLogger(__name__)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+@pytest.mark.xfail
+async def test_macaroon_auth(event_loop):
+ auth_info, username = agent_auth_info()
+ # Create a bakery client that can do agent authentication.
+ client = httpbakery.Client(
+ key=auth_info.key,
+ interaction_methods=[agent.AgentInteractor(auth_info)],
+ )
+
+ async with base.CleanModel(bakery_client=client) as m:
+ async with await m.get_controller() as c:
+ await c.grant_model(username, m.info.uuid, 'admin')
+ async with Model(
+ jujudata=NoAccountsJujuData(m._connector.jujudata),
+ bakery_client=client,
+ ):
+ pass
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+@pytest.mark.xfail
+async def test_macaroon_auth_with_bad_key(event_loop):
+ auth_info, username = agent_auth_info()
+ # Use a random key rather than the correct key.
+ auth_info = auth_info._replace(key=bakery.generate_key())
+ # Create a bakery client can do agent authentication.
+ client = httpbakery.Client(
+ key=auth_info.key,
+ interaction_methods=[agent.AgentInteractor(auth_info)],
+ )
+
+ async with base.CleanModel(bakery_client=client) as m:
+ async with await m.get_controller() as c:
+ await c.grant_model(username, m.info.uuid, 'admin')
+ try:
+ async with Model(
+ jujudata=NoAccountsJujuData(m._connector.jujudata),
+ bakery_client=client,
+ ):
+ pytest.fail('Should not be able to connect with invalid key')
+ except httpbakery.BakeryException:
+ # We're expecting this because we're using the
+ # wrong key.
+ pass
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_macaroon_auth_with_unauthorized_user(event_loop):
+ auth_info, username = agent_auth_info()
+ # Create a bakery client can do agent authentication.
+ client = httpbakery.Client(
+ key=auth_info.key,
+ interaction_methods=[agent.AgentInteractor(auth_info)],
+ )
+ async with base.CleanModel(bakery_client=client) as m:
+ # Note: no grant of rights to the agent user.
+ try:
+ async with Model(
+ jujudata=NoAccountsJujuData(m._connector.jujudata),
+ bakery_client=client,
+ ):
+ pytest.fail('Should not be able to connect without grant')
+ except (JujuAPIError, httpbakery.DischargeError):
+ # We're expecting this because we're using the
+ # wrong user name.
+ pass
+
+
+def agent_auth_info():
+ agent_data = os.environ.get('TEST_AGENTS')
+ if agent_data is None:
+ pytest.skip('skipping macaroon_auth because no TEST_AGENTS '
+ 'environment variable is set')
+ auth_info = agent.read_auth_info(agent_data)
+ if len(auth_info.agents) != 1:
+ raise Exception('TEST_AGENTS agent data requires exactly one agent')
+ return auth_info, auth_info.agents[0].username
+
+
+class NoAccountsJujuData:
+ def __init__(self, jujudata):
+ self.__jujudata = jujudata
+
+ def __getattr__(self, name):
+ return getattr(self.__jujudata, name)
+
+ def accounts(self):
+ return {}
diff --git a/modules/libjuju/tests/integration/test_machine.py b/modules/libjuju/tests/integration/test_machine.py
index 8957ae1..9a5f075 100644
--- a/modules/libjuju/tests/integration/test_machine.py
+++ b/modules/libjuju/tests/integration/test_machine.py
@@ -26,18 +26,15 @@
assert machine.agent_status == 'pending'
assert not machine.agent_version
+ # there is some inconsistency in the capitalization of status_message
+ # between different providers
await asyncio.wait_for(
- model.block_until(lambda: (machine.status == 'running' and
- machine.agent_status == 'started' and
- machine.agent_version is not None)),
+ model.block_until(
+ lambda: (machine.status == 'running' and
+ machine.status_message.lower() == '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
diff --git a/modules/libjuju/tests/integration/test_model.py b/modules/libjuju/tests/integration/test_model.py
index ba2da92..1cba79a 100644
--- a/modules/libjuju/tests/integration/test_model.py
+++ b/modules/libjuju/tests/integration/test_model.py
@@ -6,6 +6,12 @@
from juju.client.client import ConfigValue, ApplicationFacade
from juju.model import Model, ModelObserver
from juju.utils import block_until, run_with_interrupt
+from juju.errors import JujuError
+
+import os
+import pylxd
+import time
+import uuid
import pytest
@@ -20,7 +26,6 @@
@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'
mini_bundle_file_path = bundle_path / 'mini-bundle.yaml'
@@ -35,6 +40,16 @@
@base.bootstrapped
@pytest.mark.asyncio
+async def test_deploy_invalid_bundle(event_loop):
+ tests_dir = Path(__file__).absolute().parent.parent
+ bundle_path = tests_dir / 'bundle' / 'invalid.yaml'
+ async with base.CleanModel() as model:
+ with pytest.raises(JujuError):
+ await model.deploy(str(bundle_path))
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
async def test_deploy_local_charm(event_loop):
from pathlib import Path
tests_dir = Path(__file__).absolute().parent.parent
@@ -112,6 +127,114 @@
@base.bootstrapped
@pytest.mark.asyncio
+async def test_add_manual_machine_ssh(event_loop):
+
+ # Verify controller is localhost
+ async with base.CleanController() as controller:
+ cloud = await controller.get_cloud()
+ if cloud != "localhost":
+ pytest.skip('Skipping because test requires lxd.')
+
+ async with base.CleanModel() as model:
+ private_key_path = os.path.expanduser(
+ "~/.local/share/juju/ssh/juju_id_rsa"
+ )
+ public_key_path = os.path.expanduser(
+ "~/.local/share/juju/ssh/juju_id_rsa.pub"
+ )
+
+ # Use the self-signed cert generated by lxc on first run
+ crt = os.path.expanduser('~/snap/lxd/current/.config/lxc/client.crt')
+ assert os.path.exists(crt)
+
+ key = os.path.expanduser('~/snap/lxd/current/.config/lxc/client.key')
+ assert os.path.exists(key)
+
+ client = pylxd.Client(
+ endpoint="https://127.0.0.1:8443",
+ cert=(crt, key),
+ verify=False,
+ )
+
+ test_name = "test-{}-add-manual-machine-ssh".format(
+ uuid.uuid4().hex[-4:]
+ )
+
+ # create profile w/cloud-init and juju ssh key
+ public_key = ""
+ with open(public_key_path, "r") as f:
+ public_key = f.readline()
+
+ profile = client.profiles.create(
+ test_name,
+ config={'user.user-data': '#cloud-config\nssh_authorized_keys:\n- {}'.format(public_key)},
+ devices={
+ 'root': {'path': '/', 'pool': 'default', 'type': 'disk'},
+ 'eth0': {
+ 'nictype': 'bridged',
+ 'parent': 'lxdbr0',
+ 'type': 'nic'
+ }
+ }
+ )
+
+ # create lxc machine
+ config = {
+ 'name': test_name,
+ 'source': {
+ 'type': 'image',
+ 'alias': 'xenial',
+ 'mode': 'pull',
+ 'protocol': 'simplestreams',
+ 'server': 'https://cloud-images.ubuntu.com/releases',
+ },
+ 'profiles': [test_name],
+ }
+ container = client.containers.create(config, wait=True)
+ container.start(wait=True)
+
+ def wait_for_network(container, timeout=30):
+ """Wait for eth0 to have an ipv4 address."""
+ starttime = time.time()
+ while(time.time() < starttime + timeout):
+ time.sleep(1)
+ if 'eth0' in container.state().network:
+ addresses = container.state().network['eth0']['addresses']
+ if len(addresses) > 0:
+ if addresses[0]['family'] == 'inet':
+ return addresses[0]
+ return None
+
+ host = wait_for_network(container)
+
+ # HACK: We need to give sshd a chance to bind to the interface,
+ # and pylxd's container.execute seems to be broken and fails and/or
+ # hangs trying to properly check if the service is up.
+ time.sleep(5)
+
+ if host:
+ # add a new manual machine
+ machine1 = await model.add_machine(spec='ssh:{}@{}:{}'.format(
+ "ubuntu",
+ host['address'],
+ private_key_path,
+ ))
+
+ assert len(model.machines) == 1
+
+ res = await machine1.destroy(force=True)
+
+ assert res is None
+ assert len(model.machines) == 0
+
+ container.stop(wait=True)
+ container.delete(wait=True)
+
+ profile.delete()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
async def test_relate(event_loop):
from juju.relation import Relation
@@ -269,6 +392,14 @@
assert result['extra-info'].source == 'model'
assert result['extra-info'].value == 'booyah'
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_set_constraints(event_loop):
+ async with base.CleanModel() as model:
+ await model.set_constraints({'cpu-power': 1})
+ cons = await model.get_constraints()
+ assert cons['cpu_power'] == 1
+
# @base.bootstrapped
# @pytest.mark.asyncio
# async def test_grant(event_loop)
diff --git a/modules/libjuju/tests/integration/test_unit.py b/modules/libjuju/tests/integration/test_unit.py
index 8b2251c..bb34969 100644
--- a/modules/libjuju/tests/integration/test_unit.py
+++ b/modules/libjuju/tests/integration/test_unit.py
@@ -25,6 +25,18 @@
assert 'Stdout' in action.results
break
+ for unit in app.units:
+ action = await unit.run('sleep 1', timeout=0.5)
+ assert isinstance(action, Action)
+ assert action.status == 'failed'
+ break
+
+ for unit in app.units:
+ action = await unit.run('sleep 0.5', timeout=2)
+ assert isinstance(action, Action)
+ assert action.status == 'completed'
+ break
+
@base.bootstrapped
@pytest.mark.asyncio
@@ -46,6 +58,10 @@
for unit in app.units:
action = await run_action(unit)
assert action.results == {'dir': '/var/git/myrepo.git'}
+ out = await model.get_action_output(action.entity_id, wait=5)
+ assert out == {'dir': '/var/git/myrepo.git'}
+ status = await model.get_action_status(uuid_or_prefix=action.entity_id)
+ assert status[action.entity_id] == 'completed'
break