Merge lp:~jimbaker/pyjuju/rel-mgmt-endpoint-factory into lp:pyjuju

Proposed by Jim Baker
Status: Merged
Approved by: Gustavo Niemeyer
Approved revision: 110
Merged at revision: 107
Proposed branch: lp:~jimbaker/pyjuju/rel-mgmt-endpoint-factory
Merge into: lp:pyjuju
Diff against target: 523 lines (+332/-26)
11 files modified
ensemble/formula/metadata.py (+108/-2)
ensemble/formula/tests/repository/mysql/metadata.yaml (+7/-0)
ensemble/formula/tests/repository/riak/metadata.yaml (+13/-0)
ensemble/formula/tests/repository/wordpress/metadata.yaml (+21/-0)
ensemble/formula/tests/test_metadata.py (+86/-4)
ensemble/state/endpoint.py (+10/-11)
ensemble/state/relation.py (+1/-0)
ensemble/state/service.py (+37/-1)
ensemble/state/tests/test_endpoint.py (+8/-7)
ensemble/state/tests/test_relation.py (+1/-1)
ensemble/state/tests/test_service.py (+40/-0)
To merge this branch: bzr merge lp:~jimbaker/pyjuju/rel-mgmt-endpoint-factory
Reviewer Review Type Date Requested Status
Gustavo Niemeyer Approve
Review via email: mp+41823@code.launchpad.net

Description of the change

Adds ensemble.state.service.ServiceStateManager.get_relation_endpoints. Originally this was to be in ensemble.state.relation, however that would require a circular import dependency (and even doing the standard workaround by putting the import at the bottom breaks for some reason in testing ensemble.control).

This branch is stacked on metadata-relations, which it relies on for testing with respect to formulas.

To post a comment you must log in.
Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Looks good overall, thanks Jim. +1, considering the following comments:

[1]

383 + try:
384 + query_service_name, query_relation_name = descriptor.split(":")
385 + except:
386 + query_service_name, query_relation_name = descriptor, None

It's generally a bad idea to use bare excepts unless the intention is really to
catch *all* exceptions, which isn't the case in most situations (e.g. this would
get even NameErrors, for instance).

This alternative approach will avoid any of those issues:

tokens = descriptor.split(":", 1)
if len(tokens) == 2: / else:

[2]

There are two terminology conflicts within Ensemble which would be good to get solved:

a) provides/requires vs. server/client as a role
b) interface vs. relation type

Can you please raise that conversation and fix the conflict using the consensus in
that conversation (or at least agree on how and when it should be fixed as part of
the discussion).

review: Approve
Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Oh, please note that the other branch this is based on has a minor comment as well.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'ensemble/formula/metadata.py'
2--- ensemble/formula/metadata.py 2010-10-29 19:55:50 +0000
3+++ ensemble/formula/metadata.py 2010-11-25 05:51:59 +0000
4@@ -5,20 +5,111 @@
5 from ensemble.errors import (
6 EnsembleError, InvalidEnsembleHeaderValue, FileNotFound)
7 from ensemble.lib.schema import (
8- SchemaError, KeyDict, Constant, Int, UnicodeOrString)
9+ SchemaError, Bool, Constant, Dict, Int,
10+ KeyDict, List, OneOf, UnicodeOrString)
11
12
13 class MetaDataError(EnsembleError):
14 """Raised when an error in the info file of a formula is found."""
15
16
17+INTERFACE_SCHEMA = KeyDict({
18+ "interface": UnicodeOrString("utf-8"),
19+ "limit": OneOf(Constant(None), Int()),
20+ "optional": Bool()})
21+
22+
23+class ReshapeInterfaceSpec(object):
24+ """Support reshaping of a YAML interface spec to list of interfaces.
25+
26+ We need this class because our formula shorthand is difficult to
27+ work with (unfortunately). So we coerce shorthand and then store
28+ the desired format in ZK.
29+
30+ Supports the following variants::
31+
32+ provides:
33+ server: riak
34+ admin: http
35+ foobar:
36+ interface: blah
37+
38+ provides:
39+ server:
40+ interface: mysql
41+ limit:
42+ optional: false
43+
44+ In all cases, this is converted to a fully specified
45+ representation, as seen in the mysql interface description above
46+ (in YAML format)
47+ """
48+
49+ value_converter = UnicodeOrString("utf-8")
50+
51+ def __init__(self, limit):
52+ """Create relation interface reshaper.
53+
54+ @limit: the limit for this relation. Used to provide defaults
55+ for a given kind of relation role (peer, provider, consumer)
56+ """
57+ self.limit = limit
58+
59+ def coerce_interface(self, interface_spec, path):
60+ """Coerce `interface_spec` from shorthand form.
61+
62+ Helper method to support each of the variants, either the
63+ formula does not specify limit and optional, such as foobar in
64+ the above example; or the interface spec is just a string,
65+ such as the ``server: riak`` example.
66+ """
67+ if not isinstance(interface_spec, dict):
68+ return {
69+ "interface":
70+ self.value_converter.coerce(interface_spec, path),
71+ "limit": self.limit,
72+ "optional": False}
73+ else:
74+ # Optional values are context-sensitive and/or have
75+ # defaults, which is different than what KeyDict can
76+ # readily support. So just do it here first, then
77+ # coerce.
78+ if "limit" not in interface_spec:
79+ interface_spec["limit"] = self.limit
80+ if "optional" not in interface_spec:
81+ interface_spec["optional"] = False
82+ return INTERFACE_SCHEMA.coerce(interface_spec, path)
83+
84+ def coerce(self, value, path):
85+ """Converts an interface specification in shorthand form.
86+
87+ @value: the value to be coerced
88+ @path: used in exception reporting"""
89+ if not isinstance(value, dict):
90+ raise SchemaError(path, "Invalid relation specification")
91+
92+ return dict(
93+ (self.value_converter.coerce(K, path),
94+ self.coerce_interface(V, path))
95+ for K, V in value.iteritems())
96+
97+
98+RELATION_SCHEMA = Dict(
99+ UnicodeOrString("utf-8"), INTERFACE_SCHEMA)
100+
101 SCHEMA = KeyDict({
102 "ensemble": Constant("formula"),
103 "name": UnicodeOrString("utf-8"),
104 "revision": Int(),
105 "summary": UnicodeOrString("utf-8"),
106 "description": UnicodeOrString("utf-8"),
107- })
108+ "peers": OneOf(RELATION_SCHEMA,
109+ ReshapeInterfaceSpec(limit=1)),
110+ "provides": OneOf(RELATION_SCHEMA,
111+ ReshapeInterfaceSpec(limit=None)),
112+ "requires": OneOf(RELATION_SCHEMA,
113+ ReshapeInterfaceSpec(limit=1)),
114+ }, optional=set(["provides", "requires", "peers"]))
115
116
117 class MetaData(object):
118@@ -59,6 +150,21 @@
119 """The formula description."""
120 return self._data.get("description")
121
122+ @property
123+ def provides(self):
124+ """The formula provides relations."""
125+ return self._data.get("provides")
126+
127+ @property
128+ def requires(self):
129+ """The formula requires relations."""
130+ return self._data.get("requires")
131+
132+ @property
133+ def peers(self):
134+ """The formula peers relations."""
135+ return self._data.get("peers")
136+
137 def get_serialization_data(self):
138 """Get internal dictionary representing the state of this instance.
139
140
141=== added directory 'ensemble/formula/tests/repository/mysql'
142=== added file 'ensemble/formula/tests/repository/mysql/metadata.yaml'
143--- ensemble/formula/tests/repository/mysql/metadata.yaml 1970-01-01 00:00:00 +0000
144+++ ensemble/formula/tests/repository/mysql/metadata.yaml 2010-11-25 05:51:59 +0000
145@@ -0,0 +1,7 @@
146+ensemble: formula
147+name: mysql
148+revision: 1
149+summary: "Database engine"
150+description: "A pretty popular database"
151+provides:
152+ server: mysql
153
154=== added directory 'ensemble/formula/tests/repository/riak'
155=== added file 'ensemble/formula/tests/repository/riak/metadata.yaml'
156--- ensemble/formula/tests/repository/riak/metadata.yaml 1970-01-01 00:00:00 +0000
157+++ ensemble/formula/tests/repository/riak/metadata.yaml 2010-11-25 05:51:59 +0000
158@@ -0,0 +1,13 @@
159+ensemble: formula
160+name: riak
161+revision: 7
162+summary: "K/V storage engine"
163+description: "Scalable K/V Store in Erlang with Clocks :-)"
164+provides:
165+ endpoint:
166+ interface: http
167+ admin:
168+ interface: http
169+peers:
170+ ring:
171+ interface: riak
172
173=== added directory 'ensemble/formula/tests/repository/wordpress'
174=== added file 'ensemble/formula/tests/repository/wordpress/metadata.yaml'
175--- ensemble/formula/tests/repository/wordpress/metadata.yaml 1970-01-01 00:00:00 +0000
176+++ ensemble/formula/tests/repository/wordpress/metadata.yaml 2010-11-25 05:51:59 +0000
177@@ -0,0 +1,21 @@
178+ensemble: formula
179+name: wordpress
180+revision: 3
181+summary: "Blog engine"
182+description: "A pretty popular blog engine"
183+provides:
184+ url:
185+ interface: http
186+ limit:
187+ optional: false
188+requires:
189+ db:
190+ interface: mysql
191+ limit: 1
192+ optional: false
193+ cache:
194+ interface: somecache
195+ limit: 2
196+ optional: true
197+
198+
199
200=== modified file 'ensemble/formula/tests/test_metadata.py'
201--- ensemble/formula/tests/test_metadata.py 2010-10-29 19:55:50 +0000
202+++ ensemble/formula/tests/test_metadata.py 2010-11-25 05:51:59 +0000
203@@ -5,14 +5,15 @@
204 import inspect
205
206 from ensemble.lib.testing import TestCase
207-from ensemble.formula.metadata import MetaData, MetaDataError
208+from ensemble.formula.metadata import (
209+ MetaData, MetaDataError, ReshapeInterfaceSpec, SchemaError)
210 from ensemble.errors import InvalidEnsembleHeaderValue, FileNotFound
211 from ensemble.formula import tests
212
213-sample_path = os.path.join(
214+test_repository_path = os.path.join(
215 os.path.dirname(inspect.getabsfile(tests)),
216- "repository", "dummy", "metadata.yaml")
217-
218+ "repository")
219+sample_path = os.path.join(test_repository_path, "dummy", "metadata.yaml")
220 sample_configuration = open(sample_path).read()
221
222
223@@ -195,3 +196,84 @@
224 self.assertRaises(AssertionError, self.metadata.get_formula_id, None)
225 self.assertRaises(AssertionError, self.metadata.get_formula_id, 1)
226 self.assertRaises(AssertionError, self.metadata.get_formula_id, "z:x")
227+
228+
229+class ParseTest(TestCase):
230+ """Test the parsing of some well-known sample files"""
231+
232+ def get_metadata(self, formula_name):
233+ metadata = MetaData(os.path.join(
234+ test_repository_path, formula_name, "metadata.yaml"))
235+ self.assertEqual(metadata.name, formula_name)
236+ return metadata
237+
238+ def test_mysql_sample(self):
239+ metadata = self.get_metadata("mysql")
240+ self.assertEqual(metadata.peers, None)
241+ self.assertEqual(
242+ metadata.provides["server"],
243+ {"interface": "mysql", "limit": None, "optional": False})
244+ self.assertEqual(metadata.requires, None)
245+
246+ def test_riak_sample(self):
247+ metadata = self.get_metadata("riak")
248+ self.assertEqual(
249+ metadata.peers["ring"],
250+ {"interface": "riak", "limit": 1, "optional": False})
251+ self.assertEqual(
252+ metadata.provides["endpoint"],
253+ {"interface": "http", "limit": None, "optional": False})
254+ self.assertEqual(
255+ metadata.provides["admin"],
256+ {"interface": "http", "limit": None, "optional": False})
257+ self.assertEqual(metadata.requires, None)
258+
259+ def test_wordpress_sample(self):
260+ metadata = self.get_metadata("wordpress")
261+ self.assertEqual(metadata.peers, None)
262+ self.assertEqual(
263+ metadata.provides["url"],
264+ {"interface": "http", "limit": None, "optional": False})
265+ self.assertEqual(
266+ metadata.requires["db"],
267+ {"interface": "mysql", "limit": 1, "optional": False})
268+ self.assertEqual(
269+ metadata.requires["cache"],
270+ {"interface": "somecache", "limit": 2, "optional": True})
271+
272+ def test_reshape_interface_spec(self):
273+ provides_reshaper = ReshapeInterfaceSpec(limit=None)
274+ self.assertEqual(
275+ provides_reshaper.coerce({"admin": "http"}, ["provides"]),
276+ {"admin": {"interface": "http", "limit": None, "optional": False}})
277+ self.assertEqual(
278+ provides_reshaper.coerce(
279+ {"admin": {"interface": "http"}}, ["provides"]),
280+ {"admin": {"interface": "http", "limit": None, "optional": False}})
281+ self.assertEqual(
282+ provides_reshaper.coerce(
283+ {"foobar": {"interface": "http", "limit": 2}}, ["provides"]),
284+ {"foobar": {"interface": "http", "limit": 2, "optional": False}})
285+ self.assertEqual(
286+ provides_reshaper.coerce(
287+ {"foobar": {"interface": "http", "optional": True}},
288+ ["provides"]),
289+ {"foobar": {"interface": "http", "limit": None, "optional": True}})
290+ self.assertRaises(
291+ SchemaError,
292+ provides_reshaper.coerce, {"admin": 42}, ["provides"])
293+ self.assertRaises(
294+ SchemaError,
295+ provides_reshaper.coerce,
296+ {"admin": {"interface": "http", "optional": None}},
297+ ["provides"])
298+ self.assertRaises(
299+ SchemaError,
300+ provides_reshaper.coerce,
301+ {"admin": {"interface": "http", "limit": "none, really"}},
302+ ["provides"])
303+
304+ consumer_reshaper = ReshapeInterfaceSpec(limit=1) # just test defaults
305+ self.assertEqual(
306+ consumer_reshaper.coerce({"admin": "http"}, ["consumes"]),
307+ {"admin": {"interface": "http", "limit": 1, "optional": False}})
308
309=== modified file 'ensemble/state/endpoint.py'
310--- ensemble/state/endpoint.py 2010-11-24 21:16:25 +0000
311+++ ensemble/state/endpoint.py 2010-11-25 05:51:59 +0000
312@@ -23,20 +23,19 @@
313 """Test whether the `other` endpoint may be used in a common relation.
314
315 RelationEndpoints may be related if they share the same relation_type
316- (which is called an "interface" in formulas) and one is a ``provider``
317- and the other is a ``consumer``; or if both endpoints have a
318- relation_role of ``peer``.
319-
320+ (which is called an "interface" in formulas) and one is a ``provides``
321+ and the other is a ``requires``; or if both endpoints have a
322+ relation_role of ``peers``.
323+
324 Raises a `TypeError` is `other` is not a `RelationEndpoint`.
325 """
326
327 if not isinstance(other, RelationEndpoint):
328 raise TypeError("Not a RelationEndpoint", other)
329 return (self.relation_type == other.relation_type and
330- ((self.relation_role == "provider" and
331- other.relation_role == "consumer") or
332- (self.relation_role == "consumer" and
333- other.relation_role == "provider") or
334- (self.relation_role == "peer" and
335- other.relation_role == "peer")))
336-
337+ ((self.relation_role == "provides" and
338+ other.relation_role == "requires") or
339+ (self.relation_role == "requires" and
340+ other.relation_role == "provides") or
341+ (self.relation_role == "peers" and
342+ other.relation_role == "peers")))
343
344=== modified file 'ensemble/state/relation.py'
345--- ensemble/state/relation.py 2010-11-15 21:08:50 +0000
346+++ ensemble/state/relation.py 2010-11-25 05:51:59 +0000
347@@ -667,3 +667,4 @@
348 """
349 return [unit_id for unit_id in units \
350 if unit_id != self._watcher_unit.internal_unit_id]
351+
352
353=== modified file 'ensemble/state/service.py'
354--- ensemble/state/service.py 2010-11-18 23:54:49 +0000
355+++ ensemble/state/service.py 2010-11-25 05:51:59 +0000
356@@ -5,11 +5,13 @@
357 from twisted.internet.defer import inlineCallbacks, returnValue
358
359 from ensemble.state.agent import AgentStateMixin
360+from ensemble.state.base import StateBase
361+from ensemble.state.endpoint import RelationEndpoint
362 from ensemble.state.errors import (
363 StateChanged, ServiceStateNotFound, ServiceUnitStateNotFound,
364 ServiceUnitStateMachineAlreadyAssigned, ServiceStateNameInUse,
365 BadServiceStateName)
366-from ensemble.state.base import StateBase
367+from ensemble.state.formula import FormulaStateManager
368 from ensemble.state.relation import ServiceRelationState
369
370
371@@ -56,6 +58,40 @@
372 raise ServiceStateNotFound(service_name)
373 returnValue(ServiceState(self._client, internal_id, service_name))
374
375+ @inlineCallbacks
376+ def get_relation_endpoints(self, descriptor):
377+ """Get all relation endpoints for `descriptor`.
378+
379+ A `descriptor` is of the form <service name>[:<relation name>].
380+ Returns an empty set if there are no endpoints matching
381+ the `descriptor`.
382+ """
383+ try:
384+ query_service_name, query_relation_name = descriptor.split(":")
385+ except:
386+ query_service_name, query_relation_name = descriptor, None
387+ formula_state_manager = FormulaStateManager(self._client)
388+ service_state = yield self.get_service_state(
389+ query_service_name)
390+ formula_id = yield service_state.get_formula_id()
391+ formula_state = yield formula_state_manager.get_formula_state(
392+ formula_id)
393+ formula_metadata = yield formula_state.get_metadata()
394+ endpoints = set()
395+ for relation_role in ("peers", "provides", "requires"):
396+ relations = getattr(formula_metadata, relation_role)
397+ if relations:
398+ for relation_name, spec in relations.iteritems():
399+ if (query_relation_name is None or
400+ query_relation_name == relation_name):
401+ endpoints.add(RelationEndpoint(
402+ service_name=query_service_name,
403+ relation_type=spec["interface"],
404+ relation_name=relation_name,
405+ relation_role=relation_role))
406+ returnValue(endpoints)
407+
408+
409
410 class ServiceState(StateBase):
411 """State of a service registered in an environment.
412
413=== modified file 'ensemble/state/tests/test_endpoint.py'
414--- ensemble/state/tests/test_endpoint.py 2010-11-24 21:25:58 +0000
415+++ ensemble/state/tests/test_endpoint.py 2010-11-25 05:51:59 +0000
416@@ -1,11 +1,12 @@
417 from ensemble.lib.testing import TestCase
418 from ensemble.state.endpoint import RelationEndpoint
419
420+
421 class RelationEndpointTest(TestCase):
422 def test_may_relate_to(self):
423- mysql_ep = RelationEndpoint("mysqldb", "mysql", "db", "provider")
424- blog_ep = RelationEndpoint("blog", "mysql", "mysql", "consumer")
425- pg_ep = RelationEndpoint("postgres", "postgres", "db", "provider")
426+ mysql_ep = RelationEndpoint("mysqldb", "mysql", "db", "provides")
427+ blog_ep = RelationEndpoint("blog", "mysql", "mysql", "requires")
428+ pg_ep = RelationEndpoint("postgres", "postgres", "db", "provides")
429
430 self.assertRaises(TypeError, mysql_ep.may_relate_to, 42)
431
432@@ -20,15 +21,15 @@
433 # antireflexive om relation_role -
434 # must be consumer AND provider or vice versa
435 self.assertFalse(blog_ep.may_relate_to(
436- RelationEndpoint("foo", "mysql", "db", "consumer")))
437+ RelationEndpoint("foo", "mysql", "db", "requires")))
438 self.assertFalse(mysql_ep.may_relate_to(
439- RelationEndpoint("foo", "mysql", "db", "provider")))
440+ RelationEndpoint("foo", "mysql", "db", "provides")))
441
442- # irreflexive for provider/consumer
443+ # irreflexive for provides/requires
444 self.assertFalse(mysql_ep.may_relate_to(mysql_ep))
445 self.assertFalse(blog_ep.may_relate_to(blog_ep))
446 self.assertFalse(pg_ep.may_relate_to(pg_ep))
447
448 # but reflexive for peers
449- riak_ep = RelationEndpoint("riak", "riak", "riak", "peer")
450+ riak_ep = RelationEndpoint("riak", "riak", "riak", "peers")
451 self.assert_(riak_ep.may_relate_to(riak_ep))
452
453=== modified file 'ensemble/state/tests/test_relation.py'
454--- ensemble/state/tests/test_relation.py 2010-11-16 22:37:44 +0000
455+++ ensemble/state/tests/test_relation.py 2010-11-25 05:51:59 +0000
456@@ -1,10 +1,10 @@
457 import functools
458+
459 import yaml
460 import zookeeper
461
462 from twisted.internet.defer import (
463 inlineCallbacks, returnValue, Deferred, fail, succeed)
464-
465 from txzookeeper import ZookeeperClient
466
467 from ensemble.state.formula import FormulaStateManager
468
469=== modified file 'ensemble/state/tests/test_service.py'
470--- ensemble/state/tests/test_service.py 2010-11-18 23:54:49 +0000
471+++ ensemble/state/tests/test_service.py 2010-11-25 05:51:59 +0000
472@@ -1,7 +1,12 @@
473+import os.path
474+
475 import yaml
476
477 from twisted.internet.defer import inlineCallbacks, Deferred, returnValue
478
479+from ensemble.formula.directory import FormulaDirectory
480+from ensemble.formula.tests.test_metadata import test_repository_path
481+from ensemble.state.endpoint import RelationEndpoint
482 from ensemble.state.formula import FormulaStateManager
483 from ensemble.state.service import ServiceStateManager
484 from ensemble.state.machine import MachineStateManager
485@@ -624,3 +629,38 @@
486
487 self.assertEqual([r.internal_relation_id for r in new_relations],
488 [relation_state2.internal_id])
489+
490+ @inlineCallbacks
491+ def add_service_from_formula(self, service_name):
492+ formula_dir = FormulaDirectory(os.path.join(
493+ test_repository_path, service_name))
494+ formula_state = yield self.formula_state_manager.add_formula_state(
495+ "namespace", formula_dir)
496+ service_state = yield self.service_state_manager.add_service_state(
497+ service_name, formula_state)
498+ returnValue(service_state)
499+
500+ @inlineCallbacks
501+ def test_get_relation_endpoints(self):
502+ yield self.add_service_from_formula("wordpress")
503+ wp_endpoints = yield self.service_state_manager.get_relation_endpoints(
504+ "wordpress")
505+ self.assertEqual(
506+ wp_endpoints,
507+ set([RelationEndpoint("wordpress", "http", "url", "provides"),
508+ RelationEndpoint("wordpress", "mysql", "db", "requires"),
509+ RelationEndpoint("wordpress", "somecache", "cache",
510+ "requires")]))
511+ yield self.add_service_from_formula("riak")
512+ riak_endpoints = yield self.service_state_manager.get_relation_endpoints(
513+ "riak:ring")
514+ self.assertEqual(
515+ riak_endpoints,
516+ set([RelationEndpoint("riak", "riak", "ring", "peers")]))
517+ yield self.add_service("nosuch") # blank formula!
518+ nosuch_endpoints = yield self.service_state_manager.get_relation_endpoints(
519+ "nosuch")
520+ self.assertEqual(nosuch_endpoints, set())
521+ nosuch_endpoints2 = yield self.service_state_manager.get_relation_endpoints(
522+ "nosuch:nonsense")
523+ self.assertEqual(nosuch_endpoints2, set())

Subscribers

People subscribed via source and target branches

to status/vote changes: