Merge lp:~bjornt/landscape-charm/package-upload-leader-change into lp:~landscape/landscape-charm/trunk

Proposed by Björn Tillenius
Status: Superseded
Proposed branch: lp:~bjornt/landscape-charm/package-upload-leader-change
Merge into: lp:~landscape/landscape-charm/trunk
Diff against target: 527 lines (+206/-63)
9 files modified
charmhelpers/core/hookenv.py (+62/-1)
charmhelpers/core/services/base.py (+30/-9)
charmhelpers/fetch/giturl.py (+7/-5)
hooks/leader-elected (+9/-0)
lib/relations/haproxy.py (+5/-4)
lib/relations/tests/test_haproxy.py (+61/-3)
lib/services.py (+11/-20)
lib/tests/stubs.py (+4/-9)
lib/tests/test_services.py (+17/-12)
To merge this branch: bzr merge lp:~bjornt/landscape-charm/package-upload-leader-change
Reviewer Review Type Date Requested Status
Landscape Pending
Landscape Pending
Review via email: mp+260933@code.launchpad.net

Description of the change

Don't make haproxy break when the landscape-server leader is removed.

If you would have multiple landscape-server units and the leader would
go away, the haproxy config would break. The reason was the
package-upload config was always present in the haproxy config, but the
actual backend was only configured by the leader.

I changed it so that the leader not only adds the backend, but also adds
the package-upload config that references the backend.

I had to make use of the is-leader executable, since the leader related
APIs in charmhelpers is a bit buggy. I added an is_elected_leader
function with the intention that this will be ported to charmhelpers, so
I made it accept the same parameters as the existing is_elected_leader
function.

To post a comment you must log in.
303. By Björn Tillenius

Merge lp:~free.ekanayaka/landscape-charm/new-data-provider-flow, resolve conflicts..

304. By Björn Tillenius

Use is_leader() from charmhelpers.

305. By Björn Tillenius

Remove custom is_leader function.

306. By Björn Tillenius

Remove ClusterStub.

307. By Björn Tillenius

Merge trunk.

308. By Björn Tillenius

Add comment.

309. By Björn Tillenius

Remove is_leader parameter.

310. By Björn Tillenius

Remove unused method.

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmhelpers/core/hookenv.py'
2--- charmhelpers/core/hookenv.py 2015-05-19 09:33:01 +0000
3+++ charmhelpers/core/hookenv.py 2015-06-03 12:30:44 +0000
4@@ -364,11 +364,16 @@
5 relation_settings = relation_settings if relation_settings else {}
6 relation_cmd_line = ['relation-set']
7 accepts_file = "--file" in subprocess.check_output(
8- relation_cmd_line + ["--help"])
9+ relation_cmd_line + ["--help"], universal_newlines=True)
10 if relation_id is not None:
11 relation_cmd_line.extend(('-r', relation_id))
12 settings = relation_settings.copy()
13 settings.update(kwargs)
14+ for key, value in settings.items():
15+ # Force value to be a string: it always should, but some call
16+ # sites pass in things like dicts or numbers.
17+ if value is not None:
18+ settings[key] = "{}".format(value)
19 if accepts_file:
20 # --file was introduced in Juju 1.23.2. Use it by default if
21 # available, since otherwise we'll break if the relation data is
22@@ -390,6 +395,17 @@
23 flush(local_unit())
24
25
26+def relation_clear(r_id=None):
27+ ''' Clears any relation data already set on relation r_id '''
28+ settings = relation_get(rid=r_id,
29+ unit=local_unit())
30+ for setting in settings:
31+ if setting not in ['public-address', 'private-address']:
32+ settings[setting] = None
33+ relation_set(relation_id=r_id,
34+ **settings)
35+
36+
37 @cached
38 def relation_ids(reltype=None):
39 """A list of relation_ids"""
40@@ -681,3 +697,48 @@
41 return 'unknown'
42 else:
43 raise
44+
45+
46+def translate_exc(from_exc, to_exc):
47+ def inner_translate_exc1(f):
48+ def inner_translate_exc2(*args, **kwargs):
49+ try:
50+ return f(*args, **kwargs)
51+ except from_exc:
52+ raise to_exc
53+
54+ return inner_translate_exc2
55+
56+ return inner_translate_exc1
57+
58+
59+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
60+def is_leader():
61+ """Does the current unit hold the juju leadership
62+
63+ Uses juju to determine whether the current unit is the leader of its peers
64+ """
65+ cmd = ['is-leader', '--format=json']
66+ return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
67+
68+
69+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
70+def leader_get(attribute=None):
71+ """Juju leader get value(s)"""
72+ cmd = ['leader-get', '--format=json'] + [attribute or '-']
73+ return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
74+
75+
76+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
77+def leader_set(settings=None, **kwargs):
78+ """Juju leader set value(s)"""
79+ log("Juju leader-set '%s'" % (settings), level=DEBUG)
80+ cmd = ['leader-set']
81+ settings = settings or {}
82+ settings.update(kwargs)
83+ for k, v in settings.iteritems():
84+ if v is None:
85+ cmd.append('{}='.format(k))
86+ else:
87+ cmd.append('{}={}'.format(k, v))
88+ subprocess.check_call(cmd)
89
90=== modified file 'charmhelpers/core/services/base.py'
91--- charmhelpers/core/services/base.py 2015-05-12 14:31:50 +0000
92+++ charmhelpers/core/services/base.py 2015-06-03 12:30:44 +0000
93@@ -15,8 +15,8 @@
94 # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
95
96 import os
97-import re
98 import json
99+from inspect import getargspec
100 from collections import Iterable, OrderedDict
101
102 from charmhelpers.core import host
103@@ -132,8 +132,8 @@
104 if hook_name == 'stop':
105 self.stop_services()
106 else:
107+ self.reconfigure_services()
108 self.provide_data()
109- self.reconfigure_services()
110 cfg = hookenv.config()
111 if cfg.implicit_save:
112 cfg.save()
113@@ -145,15 +145,36 @@
114 A provider must have a `name` attribute, which indicates which relation
115 to set data on, and a `provide_data()` method, which returns a dict of
116 data to set.
117+
118+ The `provide_data()` method can optionally accept two parameters:
119+
120+ * ``remote_service`` The name of the remote service that the data will
121+ be provided to. The `provide_data()` method will be called once
122+ for each connected service (not unit). This allows the method to
123+ tailor its data to the given service.
124+ * ``service_ready`` Whether or not the service definition had all of
125+ its requirements met, and thus the ``data_ready`` callbacks run.
126+
127+ Note that the ``provided_data`` methods are now called **after** the
128+ ``data_ready`` callbacks are run. This gives the ``data_ready`` callbacks
129+ a chance to generate any data necessary for the providing to the remote
130+ services.
131 """
132- hook_name = hookenv.hook_name()
133- for service in self.services.values():
134+ for service_name, service in self.services.items():
135+ service_ready = self.is_ready(service_name)
136 for provider in service.get('provided_data', []):
137- if re.match(r'{}-relation-(joined|changed)'.format(provider.name), hook_name):
138- data = provider.provide_data()
139- _ready = provider._is_ready(data) if hasattr(provider, '_is_ready') else data
140- if _ready:
141- hookenv.relation_set(None, data)
142+ for relid in hookenv.relation_ids(provider.name):
143+ units = hookenv.related_units(relid)
144+ if not units:
145+ continue
146+ remote_service = units[0].split('/')[0]
147+ argspec = getargspec(provider.provide_data)
148+ if len(argspec.args) > 1:
149+ data = provider.provide_data(remote_service, service_ready)
150+ else:
151+ data = provider.provide_data()
152+ if data:
153+ hookenv.relation_set(relid, data)
154
155 def reconfigure_services(self, *service_names):
156 """
157
158=== modified file 'charmhelpers/fetch/giturl.py'
159--- charmhelpers/fetch/giturl.py 2015-03-12 11:42:26 +0000
160+++ charmhelpers/fetch/giturl.py 2015-06-03 12:30:44 +0000
161@@ -45,14 +45,16 @@
162 else:
163 return True
164
165- def clone(self, source, dest, branch):
166+ def clone(self, source, dest, branch, depth=None):
167 if not self.can_handle(source):
168 raise UnhandledSource("Cannot handle {}".format(source))
169
170- repo = Repo.clone_from(source, dest)
171- repo.git.checkout(branch)
172+ if depth:
173+ Repo.clone_from(source, dest, branch=branch, depth=depth)
174+ else:
175+ Repo.clone_from(source, dest, branch=branch)
176
177- def install(self, source, branch="master", dest=None):
178+ def install(self, source, branch="master", dest=None, depth=None):
179 url_parts = self.parse_url(source)
180 branch_name = url_parts.path.strip("/").split("/")[-1]
181 if dest:
182@@ -63,7 +65,7 @@
183 if not os.path.exists(dest_dir):
184 mkdir(dest_dir, perms=0o755)
185 try:
186- self.clone(source, dest_dir, branch)
187+ self.clone(source, dest_dir, branch, depth)
188 except GitCommandError as e:
189 raise UnhandledSource(e.message)
190 except OSError as e:
191
192=== added file 'hooks/leader-elected'
193--- hooks/leader-elected 1970-01-01 00:00:00 +0000
194+++ hooks/leader-elected 2015-06-03 12:30:44 +0000
195@@ -0,0 +1,9 @@
196+#!/usr/bin/python
197+import sys
198+
199+from lib.services import ServicesHook
200+
201+
202+if __name__ == "__main__":
203+ hook = ServicesHook()
204+ sys.exit(hook())
205
206=== modified file 'lib/relations/haproxy.py'
207--- lib/relations/haproxy.py 2015-05-25 09:11:59 +0000
208+++ lib/relations/haproxy.py 2015-06-03 12:30:44 +0000
209@@ -37,10 +37,8 @@
210 "http-request set-header X-Forwarded-Proto https",
211 "acl message path_beg -i /message-system",
212 "acl api path_beg -i /api",
213- "acl package-upload path_beg -i /upload",
214 "use_backend landscape-message if message",
215 "use_backend landscape-api if api",
216- "use_backend landscape-package-upload if package-upload",
217 ],
218 }
219 SERVER_BASE_PORTS = {
220@@ -100,6 +98,7 @@
221 def _get_https(self):
222 """Return the service configuration for the HTTPS frontend."""
223
224+ service = self._get_service("https")
225 backends = [
226 self._get_backend("message", self._get_servers("message-server")),
227 self._get_backend("api", self._get_servers("api")),
228@@ -108,6 +107,9 @@
229 self._hookenv.log(
230 "This unit is the juju leader: Writing package-upload backends"
231 " entry.")
232+ service["service_options"].extend([
233+ "acl package-upload path_beg -i /upload",
234+ "use_backend landscape-package-upload if package-upload"])
235 backends.append(
236 self._get_backend(
237 "package-upload", self._get_servers("package-upload")))
238@@ -116,7 +118,6 @@
239 "This unit is not the juju leader: not writing package-upload"
240 " backends entry.")
241
242- service = self._get_service("https")
243 service.update({
244 "crts": self._get_ssl_certificate(),
245 "servers": self._get_servers("appserver"),
246@@ -136,7 +137,7 @@
247 "service_name": "landscape-%s" % name,
248 "service_host": "0.0.0.0",
249 "service_port": SERVICE_PORTS[name],
250- "service_options": SERVICE_OPTIONS[name],
251+ "service_options": SERVICE_OPTIONS[name][:],
252 "errorfiles": self._get_error_files()
253 }
254
255
256=== modified file 'lib/relations/tests/test_haproxy.py'
257--- lib/relations/tests/test_haproxy.py 2015-06-02 09:21:44 +0000
258+++ lib/relations/tests/test_haproxy.py 2015-06-03 12:30:44 +0000
259@@ -96,10 +96,8 @@
260 "http-request set-header X-Forwarded-Proto https",
261 "acl message path_beg -i /message-system",
262 "acl api path_beg -i /api",
263- "acl package-upload path_beg -i /upload",
264 "use_backend landscape-message if message",
265- "use_backend landscape-api if api",
266- "use_backend landscape-package-upload if package-upload"],
267+ "use_backend landscape-api if api"],
268 "errorfiles": expected_errorfiles,
269 "crts": expected_certs,
270 "servers": [
271@@ -162,6 +160,66 @@
272
273 self.assertRaises(HookError, provider.provide_data)
274
275+ def test_provide_data_package_upload_leader(self):
276+ """
277+ If the unit is a leader, package-upload config is provided for
278+ the https service, but not for http.
279+ """
280+ relation = HAProxyProvider(
281+ SAMPLE_SERVICE_COUNT_DATA, paths=self.paths, is_leader=True)
282+
283+ data = relation.provide_data()
284+
285+ [http, https] = yaml.safe_load(data["services"])
286+ self.assertNotIn(
287+ "acl package-upload path_beg -i /upload",
288+ http["service_options"])
289+ self.assertNotIn(
290+ "use_backend landscape-package-upload if package-upload",
291+ http["service_options"])
292+ self.assertNotIn(
293+ "landscape-package-upload",
294+ [backend["backend_name"] for backend in http["backends"]])
295+ self.assertIn(
296+ "acl package-upload path_beg -i /upload",
297+ https["service_options"])
298+ self.assertIn(
299+ "use_backend landscape-package-upload if package-upload",
300+ https["service_options"])
301+ self.assertIn(
302+ "landscape-package-upload",
303+ [backend["backend_name"] for backend in https["backends"]])
304+
305+ def test_provide_data_package_upload_no_leader(self):
306+ """
307+ If the unit is not a leader, package-upload config isn't
308+ provided for neither the https nor http services.
309+ """
310+ relation = HAProxyProvider(
311+ SAMPLE_SERVICE_COUNT_DATA, paths=self.paths, is_leader=False)
312+
313+ data = relation.provide_data()
314+
315+ [http, https] = yaml.safe_load(data["services"])
316+ self.assertNotIn(
317+ "acl package-upload path_beg -i /upload",
318+ http["service_options"])
319+ self.assertNotIn(
320+ "use_backend landscape-package-upload if package-upload",
321+ http["service_options"])
322+ self.assertNotIn(
323+ "landscape-package-upload",
324+ [backend["backend_name"] for backend in http["backends"]])
325+ self.assertNotIn(
326+ "acl package-upload path_beg -i /upload",
327+ https["service_options"])
328+ self.assertNotIn(
329+ "use_backend landscape-package-upload if package-upload",
330+ https["service_options"])
331+ self.assertNotIn(
332+ "landscape-package-upload",
333+ [backend["backend_name"] for backend in https["backends"]])
334+
335 def test_default_ssl_cert_is_used_without_config_keys(self):
336 """
337 If no "ssl-cert" is specified, the provide_data method returns
338
339=== modified file 'lib/services.py'
340--- lib/services.py 2015-06-02 09:21:44 +0000
341+++ lib/services.py 2015-06-03 12:30:44 +0000
342@@ -5,7 +5,6 @@
343 from charmhelpers.core import host
344 from charmhelpers.core.services.base import ServiceManager
345 from charmhelpers.core.services.helpers import render_template
346-from charmhelpers.contrib.hahelpers import cluster
347
348 from lib.hook import Hook
349 from lib.paths import default_paths
350@@ -36,11 +35,10 @@
351 all relation data we need in order to configure this Landscape unit, and
352 proceed with the configuration if ready.
353 """
354- def __init__(self, hookenv=hookenv, cluster=cluster, host=host,
355+ def __init__(self, hookenv=hookenv, host=host,
356 subprocess=subprocess, paths=default_paths, fetch=fetch):
357 super(ServicesHook, self).__init__(hookenv=hookenv)
358 self._hookenv = hookenv
359- self._cluster = cluster
360 self._host = host
361 self._paths = paths
362 self._subprocess = subprocess
363@@ -48,20 +46,18 @@
364
365 def _run(self):
366 leader_context = None
367- is_leader = self._cluster.is_elected_leader(None)
368+ is_leader = self._hookenv.is_leader()
369 if is_leader:
370 leader_context = LandscapeLeaderContext(
371 host=self._host, hookenv=self._hookenv)
372
373- haproxy_provider = HAProxyProvider(
374- SERVICE_COUNTS, paths=self._paths, is_leader=is_leader)
375-
376 manager = ServiceManager(services=[{
377 "service": "landscape",
378 "ports": [],
379 "provided_data": [
380 LandscapeProvider(leader_context),
381- haproxy_provider,
382+ HAProxyProvider(
383+ SERVICE_COUNTS, paths=self._paths, is_leader=is_leader),
384 RabbitMQProvider(),
385 ],
386 # Required data is available to the render_template calls below.
387@@ -94,16 +90,11 @@
388 "start": LSCtl(subprocess=self._subprocess, hookenv=self._hookenv),
389 }])
390
391- # XXX The services framework only triggers data providers within the
392- # context of relation joined/changed hooks, however we also
393- # want to trigger the haproxy provider if the SSL certificate
394- # has changed.
395- if self._hookenv.hook_name() == "config-changed":
396- config = self._hookenv.config()
397- if config.changed("ssl-cert") or config.changed("ssl-key"):
398- relation_ids = self._hookenv.relation_ids(HAProxyProvider.name)
399- data = haproxy_provider.provide_data()
400- for relation_id in relation_ids:
401- self._hookenv.relation_set(relation_id, data)
402-
403 manager.manage()
404+
405+ def _set_haproxy_data(self, haproxy_provider):
406+ """Provide and set the data in the haproxy relation."""
407+ relation_ids = self._hookenv.relation_ids(HAProxyProvider.name)
408+ data = haproxy_provider.provide_data()
409+ for relation_id in relation_ids:
410+ self._hookenv.relation_set(relation_id, data)
411
412=== modified file 'lib/tests/stubs.py'
413--- lib/tests/stubs.py 2015-06-02 07:06:30 +0000
414+++ lib/tests/stubs.py 2015-06-03 12:30:44 +0000
415@@ -10,6 +10,7 @@
416 hook = "some-hook"
417 unit = "landscape-server/0"
418 relid = None
419+ leader = True
420
421 def __init__(self, charm_dir):
422 self.messages = []
423@@ -68,6 +69,9 @@
424 def action_fail(self, message):
425 self._action_fails.append(message)
426
427+ def is_leader(self):
428+ return self.leader
429+
430
431 class FetchStub(object):
432 """Provide a testable stub for C{charmhelpers.fetch}."""
433@@ -92,15 +96,6 @@
434 self.installed.append((packages, options, fatal))
435
436
437-class ClusterStub(object):
438- """Testable stub for C{charmhelpers.contrib.hahelpers.cluster}."""
439-
440- leader = True
441-
442- def is_elected_leader(self, resource):
443- return self.leader
444-
445-
446 class HostStub(object):
447 """Testable stub for C{charmhelpers.core.host}."""
448
449
450=== modified file 'lib/tests/test_services.py'
451--- lib/tests/test_services.py 2015-05-26 12:05:02 +0000
452+++ lib/tests/test_services.py 2015-06-03 12:30:44 +0000
453@@ -5,7 +5,7 @@
454 from charmhelpers.core import templating
455
456 from lib.tests.helpers import HookenvTest
457-from lib.tests.stubs import ClusterStub, HostStub, SubprocessStub, FetchStub
458+from lib.tests.stubs import HostStub, SubprocessStub, FetchStub
459 from lib.tests.sample import (
460 SAMPLE_DB_UNIT_DATA, SAMPLE_LEADER_CONTEXT_DATA, SAMPLE_AMQP_UNIT_DATA,
461 SAMPLE_CONFIG_LICENSE_DATA, SAMPLE_CONFIG_OPENID_DATA, SAMPLE_HOSTED_DATA,
462@@ -21,7 +21,6 @@
463
464 def setUp(self):
465 super(ServicesHookTest, self).setUp()
466- self.cluster = ClusterStub()
467 self.host = HostStub()
468 self.subprocess = SubprocessStub()
469 self.subprocess.add_fake_executable(SCHEMA_SCRIPT)
470@@ -31,8 +30,8 @@
471 self.root_dir = self.useFixture(RootDir())
472 self.fetch = FetchStub()
473 self.hook = ServicesHook(
474- hookenv=self.hookenv, cluster=self.cluster, host=self.host,
475- subprocess=self.subprocess, paths=self.paths, fetch=self.fetch)
476+ hookenv=self.hookenv, host=self.host, subprocess=self.subprocess,
477+ paths=self.paths, fetch=self.fetch)
478
479 # XXX Monkey patch the templating API, charmhelpers doesn't sport
480 # any dependency injection here as well.
481@@ -77,12 +76,8 @@
482 self.hook()
483 # Assert that the HAProxyProvider has run by checking that it set the
484 # relation with the dict returned by HAProxyProvider.provide_data (the
485- # only key of that dict is 'services'). The ID of relation being set
486- # is None because we're running in the website-relation-joined hook
487- # and are using the default relation ID (which in a real-world
488- # relation-set run will resolve to the relation for the http
489- # interface).
490- self.assertIn("services", self.hookenv.relations[None])
491+ # only key of that dict is 'services').
492+ self.assertIn("services", self.hookenv.relations["website:1"])
493
494 def test_amqp_relation_not_ready(self):
495 """
496@@ -179,7 +174,7 @@
497 If we're not the leader unit and we didn't yet get relation data from
498 the leader, we are not ready.
499 """
500- self.cluster.leader = False
501+ self.hookenv.leader = False
502 self.hook()
503 self.assertIn(
504 ("Incomplete relation: LandscapeRequirer", "DEBUG"),
505@@ -190,7 +185,7 @@
506 If we're not the leader unit and we got relation data from the leader,
507 along with the rest of required relations, then we're good.
508 """
509- self.cluster.leader = False
510+ self.hookenv.leader = False
511 self.hookenv.relations.update({
512 "cluster": {
513 "cluster:1": {
514@@ -236,3 +231,13 @@
515 self.hook()
516 data = yaml.load(self.hookenv.relations["website:1"]["services"])
517 self.assertIsNotNone(data)
518+
519+ def test_leader_elected(self):
520+ """
521+ When a leader is elected, the ServicesHook sets the haproxy
522+ relation data.
523+ """
524+ self.hookenv.hook = "leader-elected"
525+ self.hook()
526+ data = yaml.load(self.hookenv.relations["website:1"]["services"])
527+ self.assertIsNotNone(data)

Subscribers

People subscribed via source and target branches