Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions examples/crossmodel_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""
This example:

1. Connects to test and test2 controllers
2. Creates models on each controllers
3. Deploys a charm and waits until it reports itself active
4. Creates an offer
5. Lists the offer
6. Consumes the offer
7. Exports the bundle
8. Removes the SAAS
9. Removes the offer
10. Destroys models and disconnects
"""
import tempfile
from logging import getLogger

from juju import loop
from juju.controller import Controller

log = getLogger(__name__)


async def main():
controller1 = Controller()
print("Connecting to controller")
await controller1.connect("test")

controller2 = Controller()
print("Connecting to controller")
await controller2.connect("test2")

try:
print('Creating models')
offering_model = await controller1.add_model('test-cmr-1')
consuming_model = await controller2.add_model('test-cmr-2')

print('Deploying mysql')
application = await offering_model.deploy(
'mysql',
application_name='mysql',
series='trusty',
channel='stable',
)

print('Waiting for active')
await offering_model.block_until(
lambda: all(unit.workload_status == 'active'
for unit in application.units))

print('Adding offer')
await offering_model.create_offer("mysql:db")

offers = await offering_model.list_offers()
print('Show offers', ', '.join("%s: %s" % item for offer in offers.results for item in vars(offer).items()))

print('Consuming offer')
await consuming_model.consume("admin/test-cmr-1.mysql", controller_name="test")

print('Exporting bundle')
with tempfile.TemporaryDirectory() as dirpath:
await offering_model.export_bundle("{}/bundle.yaml".format(dirpath))

print("Remove SAAS")
await consuming_model.remove_saas("mysql")

print('Removing offer')
await offering_model.remove_offer("admin/test-cmr-1.mysql", force=True)

print('Destroying models')
await controller1.destroy_model(offering_model.info.uuid)
await controller2.destroy_model(consuming_model.info.uuid)

except Exception:
log.exception("Example failed!")
raise

finally:
print('Disconnecting from controller')
await controller1.disconnect()
await controller2.disconnect()


if __name__ == '__main__':
loop.run(main())
6 changes: 5 additions & 1 deletion juju/client/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,17 @@
'Backups': {'versions': [1, 2]},
'Block': {'versions': [2]},
'Bundle': {'versions': [1, 2, 3]},
'CharmHub': {'versions': [1]},
'CharmRevisionUpdater': {'versions': [2]},
'Charms': {'versions': [2]},
'Cleaner': {'versions': [2]},
'Client': {'versions': [1, 2]},
'Cloud': {'versions': [1, 2, 3, 4, 5]},
'CAASAdmission': {'versions': [1]},
'CAASApplication': {'versions': [1]},
'CAASApplicationProvisioner': {'versions': [1]},
'CAASFirewaller': {'versions': [1]},
'CAASFirewallerEmbedded': {'versions': [1]},
'CAASOperator': {'versions': [1]},
'CAASAgent': {'versions': [1]},
'CAASOperatorProvisioner': {'versions': [1]},
Expand Down Expand Up @@ -482,7 +486,7 @@ async def rpc(self, msg, encoder=None):
# errors, or perhaps a keyword parameter to the rpc method
# could be added to trigger this behaviour.
err_results = []
for res in result['response']['results']:
for res in result['response']['results'] or []:
if res.get('error', {}).get('message'):
err_results.append(res['error']['message'])
if err_results:
Expand Down
3 changes: 3 additions & 0 deletions juju/delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def get_entity_class(self):


class ModelDelta(EntityDelta):
def get_id(self):
return self.data['model-uuid']

@classmethod
def get_entity_class(self):
from .model import ModelInfo
Expand Down
53 changes: 43 additions & 10 deletions juju/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ def entity_type(self):
'application' or 'unit', etc.

"""
# Allow the overriding of entity names from the type instead of from
# the class name. Useful because Model and ModelInfo clash and we really
# want ModelInfo to be called Model.
if hasattr(self.__class__, "type_name_override") and callable(self.__class__.type_name_override):
return self.__class__.type_name_override()

def first_lower(s):
if len(s) == 0:
return s
Expand Down Expand Up @@ -449,6 +455,7 @@ def __init__(
self._observers = weakref.WeakValueDictionary()
self.state = ModelState(self)
self._info = None
self._mode = None
self._watch_stopping = asyncio.Event(loop=self._connector.loop)
self._watch_stopped = asyncio.Event(loop=self._connector.loop)
self._watch_received = asyncio.Event(loop=self._connector.loop)
Expand Down Expand Up @@ -797,6 +804,10 @@ def info(self):
"""
return self._info

@property
def strict_mode(self):
return self._mode is not None and "strict" in self._mode

def add_observer(
self, callable_, entity_type=None, action=None, entity_id=None,
predicate=None):
Expand Down Expand Up @@ -843,7 +854,21 @@ def _watch(self):
See :meth:`add_observer` to register an onchange callback.

"""
def _post_step(obj):
# Once we get the model, ensure we're running in the correct state
# as a post step.
if isinstance(obj, ModelInfo) and obj.safe_data is not None:
model_config = obj.safe_data["config"]
if "mode" in model_config:
self._mode = model_config["mode"]

async def _all_watcher():
# First attempt to get the model config so we know what mode the
# library should be running in.
model_config = await self.get_config()
if "mode" in model_config:
self._mode = model_config["mode"]["value"]

try:
allwatcher = client.AllWatcherFacade.from_connection(
self.connection())
Expand Down Expand Up @@ -892,17 +917,20 @@ async def _all_watcher():
pass # can't stop on a closed conn
break
for delta in results.deltas:
entity = None
try:
delta = get_entity_delta(delta)
old_obj, new_obj = self.state.apply_delta(delta)
await self._notify_observers(delta, old_obj, new_obj)
except KeyError as e:
# TODO (stickupkid): we should raise the unknown delta
# type, so we handle correctly all the types comming from
# the all watcher. Currently they're ignored, causing
# issue.
# raise JujuError("unknown delta type {}".format(e.args))
log.warning("unknown delta type: %s", e.args[0])
entity = get_entity_delta(delta)
except KeyError:
if self.strict_mode:
raise JujuError("unknown delta type '{}'".format(delta.entity))

if not self.strict_mode and entity is None:
continue
old_obj, new_obj = self.state.apply_delta(entity)
await self._notify_observers(entity, old_obj, new_obj)
# Post step ensure that we can handle any settings
# that need to be correctly set as a post step.
_post_step(new_obj)
self._watch_received.set()
except CancelledError:
pass
Expand Down Expand Up @@ -2270,6 +2298,11 @@ def _ignore(self, path):


class ModelInfo(ModelEntity):

@property
def tag(self):
return tag.model(self.uuid)

@staticmethod
def type_name_override():
return "model"
30 changes: 30 additions & 0 deletions tests/unit/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,33 @@ async def test_follow_redirect(event_loop):
if con:
assert con.connect_params()['endpoint'] == "42.42.42.42:4242"
await con.close()


@pytest.mark.asyncio
async def test_rpc_none_results(event_loop):
ws = WebsocketMock([
{'request-id': 1, 'response': {'results': None}},
])
expected_responses = [
{'request-id': 1, 'response': {'results': None}},
]
minimal_facades = [{'name': 'Pinger', 'versions': [1]}]
con = None
try:
with \
mock.patch('websockets.connect', base.AsyncMock(return_value=ws)), \
mock.patch(
'juju.client.connection.Connection.login',
base.AsyncMock(return_value={'response': {
'facades': minimal_facades,
}}),
), \
mock.patch('juju.client.connection.Connection._get_ssl'), \
mock.patch('juju.client.connection.Connection._pinger', base.AsyncMock()):
con = await Connection.connect('0.1.2.3:999')
actual_responses = []
actual_responses.append(await con.rpc({'version': 1}))
assert actual_responses == expected_responses
finally:
if con:
await con.close()