Skip to content

Commit eb849e4

Browse files
authored
Merge pull request #455 from SimonRichardson/2.9
#455 This is a clean merge from master to 2.9 to ensure that 2.9 is kept in sync.
2 parents 1a77394 + c3c4bf7 commit eb849e4

5 files changed

Lines changed: 166 additions & 11 deletions

File tree

examples/crossmodel_controller.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
This example:
3+
4+
1. Connects to test and test2 controllers
5+
2. Creates models on each controllers
6+
3. Deploys a charm and waits until it reports itself active
7+
4. Creates an offer
8+
5. Lists the offer
9+
6. Consumes the offer
10+
7. Exports the bundle
11+
8. Removes the SAAS
12+
9. Removes the offer
13+
10. Destroys models and disconnects
14+
"""
15+
import tempfile
16+
from logging import getLogger
17+
18+
from juju import loop
19+
from juju.controller import Controller
20+
21+
log = getLogger(__name__)
22+
23+
24+
async def main():
25+
controller1 = Controller()
26+
print("Connecting to controller")
27+
await controller1.connect("test")
28+
29+
controller2 = Controller()
30+
print("Connecting to controller")
31+
await controller2.connect("test2")
32+
33+
try:
34+
print('Creating models')
35+
offering_model = await controller1.add_model('test-cmr-1')
36+
consuming_model = await controller2.add_model('test-cmr-2')
37+
38+
print('Deploying mysql')
39+
application = await offering_model.deploy(
40+
'mysql',
41+
application_name='mysql',
42+
series='trusty',
43+
channel='stable',
44+
)
45+
46+
print('Waiting for active')
47+
await offering_model.block_until(
48+
lambda: all(unit.workload_status == 'active'
49+
for unit in application.units))
50+
51+
print('Adding offer')
52+
await offering_model.create_offer("mysql:db")
53+
54+
offers = await offering_model.list_offers()
55+
print('Show offers', ', '.join("%s: %s" % item for offer in offers.results for item in vars(offer).items()))
56+
57+
print('Consuming offer')
58+
await consuming_model.consume("admin/test-cmr-1.mysql", controller_name="test")
59+
60+
print('Exporting bundle')
61+
with tempfile.TemporaryDirectory() as dirpath:
62+
await offering_model.export_bundle("{}/bundle.yaml".format(dirpath))
63+
64+
print("Remove SAAS")
65+
await consuming_model.remove_saas("mysql")
66+
67+
print('Removing offer')
68+
await offering_model.remove_offer("admin/test-cmr-1.mysql", force=True)
69+
70+
print('Destroying models')
71+
await controller1.destroy_model(offering_model.info.uuid)
72+
await controller2.destroy_model(consuming_model.info.uuid)
73+
74+
except Exception:
75+
log.exception("Example failed!")
76+
raise
77+
78+
finally:
79+
print('Disconnecting from controller')
80+
await controller1.disconnect()
81+
await controller2.disconnect()
82+
83+
84+
if __name__ == '__main__':
85+
loop.run(main())

juju/client/connection.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@
2929
'Backups': {'versions': [1, 2]},
3030
'Block': {'versions': [2]},
3131
'Bundle': {'versions': [1, 2, 3]},
32+
'CharmHub': {'versions': [1]},
3233
'CharmRevisionUpdater': {'versions': [2]},
3334
'Charms': {'versions': [2]},
3435
'Cleaner': {'versions': [2]},
3536
'Client': {'versions': [1, 2]},
3637
'Cloud': {'versions': [1, 2, 3, 4, 5]},
3738
'CAASAdmission': {'versions': [1]},
39+
'CAASApplication': {'versions': [1]},
40+
'CAASApplicationProvisioner': {'versions': [1]},
3841
'CAASFirewaller': {'versions': [1]},
42+
'CAASFirewallerEmbedded': {'versions': [1]},
3943
'CAASOperator': {'versions': [1]},
4044
'CAASAgent': {'versions': [1]},
4145
'CAASOperatorProvisioner': {'versions': [1]},
@@ -482,7 +486,7 @@ async def rpc(self, msg, encoder=None):
482486
# errors, or perhaps a keyword parameter to the rpc method
483487
# could be added to trigger this behaviour.
484488
err_results = []
485-
for res in result['response']['results']:
489+
for res in result['response']['results'] or []:
486490
if res.get('error', {}).get('message'):
487491
err_results.append(res['error']['message'])
488492
if err_results:

juju/delta.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def get_entity_class(self):
4646

4747

4848
class ModelDelta(EntityDelta):
49+
def get_id(self):
50+
return self.data['model-uuid']
51+
4952
@classmethod
5053
def get_entity_class(self):
5154
from .model import ModelInfo

juju/model.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ def entity_type(self):
301301
'application' or 'unit', etc.
302302
303303
"""
304+
# Allow the overriding of entity names from the type instead of from
305+
# the class name. Useful because Model and ModelInfo clash and we really
306+
# want ModelInfo to be called Model.
307+
if hasattr(self.__class__, "type_name_override") and callable(self.__class__.type_name_override):
308+
return self.__class__.type_name_override()
309+
304310
def first_lower(s):
305311
if len(s) == 0:
306312
return s
@@ -449,6 +455,7 @@ def __init__(
449455
self._observers = weakref.WeakValueDictionary()
450456
self.state = ModelState(self)
451457
self._info = None
458+
self._mode = None
452459
self._watch_stopping = asyncio.Event(loop=self._connector.loop)
453460
self._watch_stopped = asyncio.Event(loop=self._connector.loop)
454461
self._watch_received = asyncio.Event(loop=self._connector.loop)
@@ -797,6 +804,10 @@ def info(self):
797804
"""
798805
return self._info
799806

807+
@property
808+
def strict_mode(self):
809+
return self._mode is not None and "strict" in self._mode
810+
800811
def add_observer(
801812
self, callable_, entity_type=None, action=None, entity_id=None,
802813
predicate=None):
@@ -843,7 +854,21 @@ def _watch(self):
843854
See :meth:`add_observer` to register an onchange callback.
844855
845856
"""
857+
def _post_step(obj):
858+
# Once we get the model, ensure we're running in the correct state
859+
# as a post step.
860+
if isinstance(obj, ModelInfo) and obj.safe_data is not None:
861+
model_config = obj.safe_data["config"]
862+
if "mode" in model_config:
863+
self._mode = model_config["mode"]
864+
846865
async def _all_watcher():
866+
# First attempt to get the model config so we know what mode the
867+
# library should be running in.
868+
model_config = await self.get_config()
869+
if "mode" in model_config:
870+
self._mode = model_config["mode"]["value"]
871+
847872
try:
848873
allwatcher = client.AllWatcherFacade.from_connection(
849874
self.connection())
@@ -892,17 +917,20 @@ async def _all_watcher():
892917
pass # can't stop on a closed conn
893918
break
894919
for delta in results.deltas:
920+
entity = None
895921
try:
896-
delta = get_entity_delta(delta)
897-
old_obj, new_obj = self.state.apply_delta(delta)
898-
await self._notify_observers(delta, old_obj, new_obj)
899-
except KeyError as e:
900-
# TODO (stickupkid): we should raise the unknown delta
901-
# type, so we handle correctly all the types comming from
902-
# the all watcher. Currently they're ignored, causing
903-
# issue.
904-
# raise JujuError("unknown delta type {}".format(e.args))
905-
log.warning("unknown delta type: %s", e.args[0])
922+
entity = get_entity_delta(delta)
923+
except KeyError:
924+
if self.strict_mode:
925+
raise JujuError("unknown delta type '{}'".format(delta.entity))
926+
927+
if not self.strict_mode and entity is None:
928+
continue
929+
old_obj, new_obj = self.state.apply_delta(entity)
930+
await self._notify_observers(entity, old_obj, new_obj)
931+
# Post step ensure that we can handle any settings
932+
# that need to be correctly set as a post step.
933+
_post_step(new_obj)
906934
self._watch_received.set()
907935
except CancelledError:
908936
pass
@@ -2270,6 +2298,11 @@ def _ignore(self, path):
22702298

22712299

22722300
class ModelInfo(ModelEntity):
2301+
22732302
@property
22742303
def tag(self):
22752304
return tag.model(self.uuid)
2305+
2306+
@staticmethod
2307+
def type_name_override():
2308+
return "model"

tests/unit/test_connection.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,33 @@ async def test_follow_redirect(event_loop):
165165
if con:
166166
assert con.connect_params()['endpoint'] == "42.42.42.42:4242"
167167
await con.close()
168+
169+
170+
@pytest.mark.asyncio
171+
async def test_rpc_none_results(event_loop):
172+
ws = WebsocketMock([
173+
{'request-id': 1, 'response': {'results': None}},
174+
])
175+
expected_responses = [
176+
{'request-id': 1, 'response': {'results': None}},
177+
]
178+
minimal_facades = [{'name': 'Pinger', 'versions': [1]}]
179+
con = None
180+
try:
181+
with \
182+
mock.patch('websockets.connect', base.AsyncMock(return_value=ws)), \
183+
mock.patch(
184+
'juju.client.connection.Connection.login',
185+
base.AsyncMock(return_value={'response': {
186+
'facades': minimal_facades,
187+
}}),
188+
), \
189+
mock.patch('juju.client.connection.Connection._get_ssl'), \
190+
mock.patch('juju.client.connection.Connection._pinger', base.AsyncMock()):
191+
con = await Connection.connect('0.1.2.3:999')
192+
actual_responses = []
193+
actual_responses.append(await con.rpc({'version': 1}))
194+
assert actual_responses == expected_responses
195+
finally:
196+
if con:
197+
await con.close()

0 commit comments

Comments
 (0)