Merge lp:~niedbalski/charms/trusty/memcached/replication into lp:charms/trusty/memcached
- Trusty Tahr (14.04)
- replication
- Merge into trunk
Proposed by
Jorge Niedbalski
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 65 | ||||
Proposed branch: | lp:~niedbalski/charms/trusty/memcached/replication | ||||
Merge into: | lp:charms/trusty/memcached | ||||
Diff against target: |
1756 lines (+1466/-21) 16 files modified
README.md (+41/-2) charm-helpers.yaml (+1/-0) config.yaml (+19/-4) hooks/charmhelpers/contrib/hahelpers/__init__.py (+15/-0) hooks/charmhelpers/contrib/hahelpers/apache.py (+82/-0) hooks/charmhelpers/contrib/hahelpers/cluster.py (+268/-0) hooks/charmhelpers/core/unitdata.py (+477/-0) hooks/memcached_hooks.py (+106/-4) hooks/memcached_utils.py (+17/-2) hooks/replication.py (+84/-0) metadata.yaml (+4/-1) revision (+1/-1) templates/memcached.conf (+3/-1) tests/10_deploy_test.py (+1/-1) tests/20_deploy_replication_test.py (+117/-0) unit_tests/test_memcached_hooks.py (+230/-5) |
||||
To merge this branch: | bzr merge lp:~niedbalski/charms/trusty/memcached/replication | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Felipe Reyes | Approve | ||
charmers | Pending | ||
Billy Olsen | Pending | ||
Review via email: mp+250087@code.launchpad.net |
This proposal supersedes a proposal from 2015-02-13.
Commit message
Description of the change
This change adds server side replication to memcached charm by using the repcached patches (http://
- Added the peer cluster relation
- Updated Readme
- Added unit/functional tests
Please note that replication only works betweeen 2 units.
To post a comment you must log in.
Revision history for this message
Billy Olsen (billy-olsen) wrote : Posted in a previous version of this proposal | # |
review:
Needs Fixing
Revision history for this message
Felipe Reyes (freyes) wrote : | # |
Jorge,
Your patch looks OK to me, the replication worked fine after the changes you made. Functional/unit tests are passing.
This is a good addition to the charm :)
Thanks,
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'README.md' |
2 | --- README.md 2014-01-15 20:04:13 +0000 |
3 | +++ README.md 2015-02-18 02:12:32 +0000 |
4 | @@ -18,6 +18,45 @@ |
5 | |
6 | The "3" in this case is an example, the `juju status memcached` will show you which machine number the service is running on so you can `juju ssh` to it. |
7 | |
8 | +# Replication |
9 | + |
10 | +The charm uses the repcached patch ( http://repcached.lab.klab.org/ ), this patch has some limitations, as |
11 | +the ability to just replicate between 2 nodes. |
12 | + |
13 | +For enabling replication create a config.yaml file with the following content: |
14 | + ```yaml |
15 | + memcached: |
16 | + repcached: True |
17 | + ``` |
18 | + |
19 | +Then deploy a maximum of 2 units of memcached: |
20 | + |
21 | + juju deploy -n 2 config.yaml memcached |
22 | + |
23 | +**Caution** : As per design limitations, If you try to add another unit of memcached, all the units will be |
24 | +set as standalone you decide to disable repcached by using `juju set memcached repcached=false` and re-deploy the |
25 | +unit. |
26 | + |
27 | +Or you can deploy 2 units and then enable replication by running |
28 | + juju deploy -n 2 memcached |
29 | + juju set memcached repcached=true |
30 | + |
31 | + |
32 | +### Removing a unit |
33 | + |
34 | +Removing one of the cluster units, means remove replication, please disable replication first |
35 | +and then remove the unit safely. |
36 | + |
37 | + juju set memcached repcached=false |
38 | + juju remove-unit memcached/0 |
39 | + |
40 | + |
41 | +### Removing replication |
42 | + |
43 | +For turning the replication support off on memcached, you need to run the following command: |
44 | + |
45 | + juju set memcached repcached=false |
46 | + |
47 | ## Example Usage |
48 | |
49 | This charm can be used with other charms, in particular make note of [these possible relations](https://jujucharms.com/fullscreen/search/precise/memcached-7/?text=memcached#bws-related-charms) |
50 | @@ -26,11 +65,11 @@ |
51 | |
52 | ## Scale out Usage |
53 | |
54 | -You can |
55 | +You can |
56 | |
57 | juju add-unit memcached |
58 | |
59 | -To add more units. Memcached doesn't share load, it's very simple and the clients have the intelligence to know which server to pick. |
60 | +To add more units. Memcached doesn't share load, it's very simple and the clients have the intelligence to know which server to pick. |
61 | |
62 | ## Known Limitations and Issues |
63 | |
64 | |
65 | === modified file 'charm-helpers.yaml' |
66 | --- charm-helpers.yaml 2014-12-04 18:52:50 +0000 |
67 | +++ charm-helpers.yaml 2015-02-18 02:12:32 +0000 |
68 | @@ -4,3 +4,4 @@ |
69 | - fetch |
70 | - core |
71 | - contrib.network |
72 | + - contrib.hahelpers |
73 | |
74 | === modified file 'config.yaml' |
75 | --- config.yaml 2014-12-01 19:57:56 +0000 |
76 | +++ config.yaml 2015-02-18 02:12:32 +0000 |
77 | @@ -75,16 +75,31 @@ |
78 | type: boolean |
79 | default: False |
80 | extra-options: |
81 | - description: memcached has many other options documented in its man page. You may pass them here as a string which will be appended to memcached's execution. |
82 | - type: string |
83 | - default: "" |
84 | + description: memcached has many other options documented in its man page. You may pass them here as a string which |
85 | + type: string |
86 | + default: "" |
87 | nagios_context: |
88 | default: "juju" |
89 | type: string |
90 | - description: > |
91 | + description: | |
92 | Used by the nrpe-external-master subordinate charm. |
93 | A string that will be prepended to instance name to set the host name |
94 | in nagios. So for instance the hostname would be something like: |
95 | juju-memcached-0 |
96 | If you're running multiple environments with the same services in them |
97 | this allows you to differentiate between them. |
98 | + repcached: |
99 | + default: False |
100 | + type: boolean |
101 | + description: | |
102 | + Enable memcached replication |
103 | + repcached_origin: |
104 | + default: "ppa:niedbalski/memcached-repcached" |
105 | + type: string |
106 | + description: | |
107 | + Memcached + Repcached package location |
108 | + repcached_port: |
109 | + default: "11212" |
110 | + type: string |
111 | + description: | |
112 | + TCP port number for replication (default: 11212) |
113 | |
114 | === added directory 'hooks/charmhelpers/contrib/hahelpers' |
115 | === added file 'hooks/charmhelpers/contrib/hahelpers/__init__.py' |
116 | --- hooks/charmhelpers/contrib/hahelpers/__init__.py 1970-01-01 00:00:00 +0000 |
117 | +++ hooks/charmhelpers/contrib/hahelpers/__init__.py 2015-02-18 02:12:32 +0000 |
118 | @@ -0,0 +1,15 @@ |
119 | +# Copyright 2014-2015 Canonical Limited. |
120 | +# |
121 | +# This file is part of charm-helpers. |
122 | +# |
123 | +# charm-helpers is free software: you can redistribute it and/or modify |
124 | +# it under the terms of the GNU Lesser General Public License version 3 as |
125 | +# published by the Free Software Foundation. |
126 | +# |
127 | +# charm-helpers is distributed in the hope that it will be useful, |
128 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
129 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
130 | +# GNU Lesser General Public License for more details. |
131 | +# |
132 | +# You should have received a copy of the GNU Lesser General Public License |
133 | +# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. |
134 | |
135 | === added file 'hooks/charmhelpers/contrib/hahelpers/apache.py' |
136 | --- hooks/charmhelpers/contrib/hahelpers/apache.py 1970-01-01 00:00:00 +0000 |
137 | +++ hooks/charmhelpers/contrib/hahelpers/apache.py 2015-02-18 02:12:32 +0000 |
138 | @@ -0,0 +1,82 @@ |
139 | +# Copyright 2014-2015 Canonical Limited. |
140 | +# |
141 | +# This file is part of charm-helpers. |
142 | +# |
143 | +# charm-helpers is free software: you can redistribute it and/or modify |
144 | +# it under the terms of the GNU Lesser General Public License version 3 as |
145 | +# published by the Free Software Foundation. |
146 | +# |
147 | +# charm-helpers is distributed in the hope that it will be useful, |
148 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
149 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
150 | +# GNU Lesser General Public License for more details. |
151 | +# |
152 | +# You should have received a copy of the GNU Lesser General Public License |
153 | +# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. |
154 | + |
155 | +# |
156 | +# Copyright 2012 Canonical Ltd. |
157 | +# |
158 | +# This file is sourced from lp:openstack-charm-helpers |
159 | +# |
160 | +# Authors: |
161 | +# James Page <james.page@ubuntu.com> |
162 | +# Adam Gandelman <adamg@ubuntu.com> |
163 | +# |
164 | + |
165 | +import subprocess |
166 | + |
167 | +from charmhelpers.core.hookenv import ( |
168 | + config as config_get, |
169 | + relation_get, |
170 | + relation_ids, |
171 | + related_units as relation_list, |
172 | + log, |
173 | + INFO, |
174 | +) |
175 | + |
176 | + |
177 | +def get_cert(cn=None): |
178 | + # TODO: deal with multiple https endpoints via charm config |
179 | + cert = config_get('ssl_cert') |
180 | + key = config_get('ssl_key') |
181 | + if not (cert and key): |
182 | + log("Inspecting identity-service relations for SSL certificate.", |
183 | + level=INFO) |
184 | + cert = key = None |
185 | + if cn: |
186 | + ssl_cert_attr = 'ssl_cert_{}'.format(cn) |
187 | + ssl_key_attr = 'ssl_key_{}'.format(cn) |
188 | + else: |
189 | + ssl_cert_attr = 'ssl_cert' |
190 | + ssl_key_attr = 'ssl_key' |
191 | + for r_id in relation_ids('identity-service'): |
192 | + for unit in relation_list(r_id): |
193 | + if not cert: |
194 | + cert = relation_get(ssl_cert_attr, |
195 | + rid=r_id, unit=unit) |
196 | + if not key: |
197 | + key = relation_get(ssl_key_attr, |
198 | + rid=r_id, unit=unit) |
199 | + return (cert, key) |
200 | + |
201 | + |
202 | +def get_ca_cert(): |
203 | + ca_cert = config_get('ssl_ca') |
204 | + if ca_cert is None: |
205 | + log("Inspecting identity-service relations for CA SSL certificate.", |
206 | + level=INFO) |
207 | + for r_id in relation_ids('identity-service'): |
208 | + for unit in relation_list(r_id): |
209 | + if ca_cert is None: |
210 | + ca_cert = relation_get('ca_cert', |
211 | + rid=r_id, unit=unit) |
212 | + return ca_cert |
213 | + |
214 | + |
215 | +def install_ca_cert(ca_cert): |
216 | + if ca_cert: |
217 | + with open('/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt', |
218 | + 'w') as crt: |
219 | + crt.write(ca_cert) |
220 | + subprocess.check_call(['update-ca-certificates', '--fresh']) |
221 | |
222 | === added file 'hooks/charmhelpers/contrib/hahelpers/cluster.py' |
223 | --- hooks/charmhelpers/contrib/hahelpers/cluster.py 1970-01-01 00:00:00 +0000 |
224 | +++ hooks/charmhelpers/contrib/hahelpers/cluster.py 2015-02-18 02:12:32 +0000 |
225 | @@ -0,0 +1,268 @@ |
226 | +# Copyright 2014-2015 Canonical Limited. |
227 | +# |
228 | +# This file is part of charm-helpers. |
229 | +# |
230 | +# charm-helpers is free software: you can redistribute it and/or modify |
231 | +# it under the terms of the GNU Lesser General Public License version 3 as |
232 | +# published by the Free Software Foundation. |
233 | +# |
234 | +# charm-helpers is distributed in the hope that it will be useful, |
235 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
236 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
237 | +# GNU Lesser General Public License for more details. |
238 | +# |
239 | +# You should have received a copy of the GNU Lesser General Public License |
240 | +# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. |
241 | + |
242 | +# |
243 | +# Copyright 2012 Canonical Ltd. |
244 | +# |
245 | +# Authors: |
246 | +# James Page <james.page@ubuntu.com> |
247 | +# Adam Gandelman <adamg@ubuntu.com> |
248 | +# |
249 | + |
250 | +""" |
251 | +Helpers for clustering and determining "cluster leadership" and other |
252 | +clustering-related helpers. |
253 | +""" |
254 | + |
255 | +import subprocess |
256 | +import os |
257 | + |
258 | +from socket import gethostname as get_unit_hostname |
259 | + |
260 | +import six |
261 | + |
262 | +from charmhelpers.core.hookenv import ( |
263 | + log, |
264 | + relation_ids, |
265 | + related_units as relation_list, |
266 | + relation_get, |
267 | + config as config_get, |
268 | + INFO, |
269 | + ERROR, |
270 | + WARNING, |
271 | + unit_get, |
272 | +) |
273 | +from charmhelpers.core.decorators import ( |
274 | + retry_on_exception, |
275 | +) |
276 | + |
277 | + |
278 | +class HAIncompleteConfig(Exception): |
279 | + pass |
280 | + |
281 | + |
282 | +class CRMResourceNotFound(Exception): |
283 | + pass |
284 | + |
285 | + |
286 | +def is_elected_leader(resource): |
287 | + """ |
288 | + Returns True if the charm executing this is the elected cluster leader. |
289 | + |
290 | + It relies on two mechanisms to determine leadership: |
291 | + 1. If the charm is part of a corosync cluster, call corosync to |
292 | + determine leadership. |
293 | + 2. If the charm is not part of a corosync cluster, the leader is |
294 | + determined as being "the alive unit with the lowest unit numer". In |
295 | + other words, the oldest surviving unit. |
296 | + """ |
297 | + if is_clustered(): |
298 | + if not is_crm_leader(resource): |
299 | + log('Deferring action to CRM leader.', level=INFO) |
300 | + return False |
301 | + else: |
302 | + peers = peer_units() |
303 | + if peers and not oldest_peer(peers): |
304 | + log('Deferring action to oldest service unit.', level=INFO) |
305 | + return False |
306 | + return True |
307 | + |
308 | + |
309 | +def is_clustered(): |
310 | + for r_id in (relation_ids('ha') or []): |
311 | + for unit in (relation_list(r_id) or []): |
312 | + clustered = relation_get('clustered', |
313 | + rid=r_id, |
314 | + unit=unit) |
315 | + if clustered: |
316 | + return True |
317 | + return False |
318 | + |
319 | + |
320 | +@retry_on_exception(5, base_delay=2, exc_type=CRMResourceNotFound) |
321 | +def is_crm_leader(resource, retry=False): |
322 | + """ |
323 | + Returns True if the charm calling this is the elected corosync leader, |
324 | + as returned by calling the external "crm" command. |
325 | + |
326 | + We allow this operation to be retried to avoid the possibility of getting a |
327 | + false negative. See LP #1396246 for more info. |
328 | + """ |
329 | + cmd = ['crm', 'resource', 'show', resource] |
330 | + try: |
331 | + status = subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
332 | + if not isinstance(status, six.text_type): |
333 | + status = six.text_type(status, "utf-8") |
334 | + except subprocess.CalledProcessError: |
335 | + status = None |
336 | + |
337 | + if status and get_unit_hostname() in status: |
338 | + return True |
339 | + |
340 | + if status and "resource %s is NOT running" % (resource) in status: |
341 | + raise CRMResourceNotFound("CRM resource %s not found" % (resource)) |
342 | + |
343 | + return False |
344 | + |
345 | + |
346 | +def is_leader(resource): |
347 | + log("is_leader is deprecated. Please consider using is_crm_leader " |
348 | + "instead.", level=WARNING) |
349 | + return is_crm_leader(resource) |
350 | + |
351 | + |
352 | +def peer_units(peer_relation="cluster"): |
353 | + peers = [] |
354 | + for r_id in (relation_ids(peer_relation) or []): |
355 | + for unit in (relation_list(r_id) or []): |
356 | + peers.append(unit) |
357 | + return peers |
358 | + |
359 | + |
360 | +def peer_ips(peer_relation='cluster', addr_key='private-address'): |
361 | + '''Return a dict of peers and their private-address''' |
362 | + peers = {} |
363 | + for r_id in relation_ids(peer_relation): |
364 | + for unit in relation_list(r_id): |
365 | + peers[unit] = relation_get(addr_key, rid=r_id, unit=unit) |
366 | + return peers |
367 | + |
368 | + |
369 | +def oldest_peer(peers): |
370 | + """Determines who the oldest peer is by comparing unit numbers.""" |
371 | + local_unit_no = int(os.getenv('JUJU_UNIT_NAME').split('/')[1]) |
372 | + for peer in peers: |
373 | + remote_unit_no = int(peer.split('/')[1]) |
374 | + if remote_unit_no < local_unit_no: |
375 | + return False |
376 | + return True |
377 | + |
378 | + |
379 | +def eligible_leader(resource): |
380 | + log("eligible_leader is deprecated. Please consider using " |
381 | + "is_elected_leader instead.", level=WARNING) |
382 | + return is_elected_leader(resource) |
383 | + |
384 | + |
385 | +def https(): |
386 | + ''' |
387 | + Determines whether enough data has been provided in configuration |
388 | + or relation data to configure HTTPS |
389 | + . |
390 | + returns: boolean |
391 | + ''' |
392 | + if config_get('use-https') == "yes": |
393 | + return True |
394 | + if config_get('ssl_cert') and config_get('ssl_key'): |
395 | + return True |
396 | + for r_id in relation_ids('identity-service'): |
397 | + for unit in relation_list(r_id): |
398 | + # TODO - needs fixing for new helper as ssl_cert/key suffixes with CN |
399 | + rel_state = [ |
400 | + relation_get('https_keystone', rid=r_id, unit=unit), |
401 | + relation_get('ca_cert', rid=r_id, unit=unit), |
402 | + ] |
403 | + # NOTE: works around (LP: #1203241) |
404 | + if (None not in rel_state) and ('' not in rel_state): |
405 | + return True |
406 | + return False |
407 | + |
408 | + |
409 | +def determine_api_port(public_port, singlenode_mode=False): |
410 | + ''' |
411 | + Determine correct API server listening port based on |
412 | + existence of HTTPS reverse proxy and/or haproxy. |
413 | + |
414 | + public_port: int: standard public port for given service |
415 | + |
416 | + singlenode_mode: boolean: Shuffle ports when only a single unit is present |
417 | + |
418 | + returns: int: the correct listening port for the API service |
419 | + ''' |
420 | + i = 0 |
421 | + if singlenode_mode: |
422 | + i += 1 |
423 | + elif len(peer_units()) > 0 or is_clustered(): |
424 | + i += 1 |
425 | + if https(): |
426 | + i += 1 |
427 | + return public_port - (i * 10) |
428 | + |
429 | + |
430 | +def determine_apache_port(public_port, singlenode_mode=False): |
431 | + ''' |
432 | + Description: Determine correct apache listening port based on public IP + |
433 | + state of the cluster. |
434 | + |
435 | + public_port: int: standard public port for given service |
436 | + |
437 | + singlenode_mode: boolean: Shuffle ports when only a single unit is present |
438 | + |
439 | + returns: int: the correct listening port for the HAProxy service |
440 | + ''' |
441 | + i = 0 |
442 | + if singlenode_mode: |
443 | + i += 1 |
444 | + elif len(peer_units()) > 0 or is_clustered(): |
445 | + i += 1 |
446 | + return public_port - (i * 10) |
447 | + |
448 | + |
449 | +def get_hacluster_config(exclude_keys=None): |
450 | + ''' |
451 | + Obtains all relevant configuration from charm configuration required |
452 | + for initiating a relation to hacluster: |
453 | + |
454 | + ha-bindiface, ha-mcastport, vip |
455 | + |
456 | + param: exclude_keys: list of setting key(s) to be excluded. |
457 | + returns: dict: A dict containing settings keyed by setting name. |
458 | + raises: HAIncompleteConfig if settings are missing. |
459 | + ''' |
460 | + settings = ['ha-bindiface', 'ha-mcastport', 'vip'] |
461 | + conf = {} |
462 | + for setting in settings: |
463 | + if exclude_keys and setting in exclude_keys: |
464 | + continue |
465 | + |
466 | + conf[setting] = config_get(setting) |
467 | + missing = [] |
468 | + [missing.append(s) for s, v in six.iteritems(conf) if v is None] |
469 | + if missing: |
470 | + log('Insufficient config data to configure hacluster.', level=ERROR) |
471 | + raise HAIncompleteConfig |
472 | + return conf |
473 | + |
474 | + |
475 | +def canonical_url(configs, vip_setting='vip'): |
476 | + ''' |
477 | + Returns the correct HTTP URL to this host given the state of HTTPS |
478 | + configuration and hacluster. |
479 | + |
480 | + :configs : OSTemplateRenderer: A config tempating object to inspect for |
481 | + a complete https context. |
482 | + |
483 | + :vip_setting: str: Setting in charm config that specifies |
484 | + VIP address. |
485 | + ''' |
486 | + scheme = 'http' |
487 | + if 'https' in configs.complete_contexts(): |
488 | + scheme = 'https' |
489 | + if is_clustered(): |
490 | + addr = config_get(vip_setting) |
491 | + else: |
492 | + addr = unit_get('private-address') |
493 | + return '%s://%s' % (scheme, addr) |
494 | |
495 | === added file 'hooks/charmhelpers/core/unitdata.py' |
496 | --- hooks/charmhelpers/core/unitdata.py 1970-01-01 00:00:00 +0000 |
497 | +++ hooks/charmhelpers/core/unitdata.py 2015-02-18 02:12:32 +0000 |
498 | @@ -0,0 +1,477 @@ |
499 | +#!/usr/bin/env python |
500 | +# -*- coding: utf-8 -*- |
501 | +# |
502 | +# Copyright 2014-2015 Canonical Limited. |
503 | +# |
504 | +# This file is part of charm-helpers. |
505 | +# |
506 | +# charm-helpers is free software: you can redistribute it and/or modify |
507 | +# it under the terms of the GNU Lesser General Public License version 3 as |
508 | +# published by the Free Software Foundation. |
509 | +# |
510 | +# charm-helpers is distributed in the hope that it will be useful, |
511 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
512 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
513 | +# GNU Lesser General Public License for more details. |
514 | +# |
515 | +# You should have received a copy of the GNU Lesser General Public License |
516 | +# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. |
517 | +# |
518 | +# |
519 | +# Authors: |
520 | +# Kapil Thangavelu <kapil.foss@gmail.com> |
521 | +# |
522 | +""" |
523 | +Intro |
524 | +----- |
525 | + |
526 | +A simple way to store state in units. This provides a key value |
527 | +storage with support for versioned, transactional operation, |
528 | +and can calculate deltas from previous values to simplify unit logic |
529 | +when processing changes. |
530 | + |
531 | + |
532 | +Hook Integration |
533 | +---------------- |
534 | + |
535 | +There are several extant frameworks for hook execution, including |
536 | + |
537 | + - charmhelpers.core.hookenv.Hooks |
538 | + - charmhelpers.core.services.ServiceManager |
539 | + |
540 | +The storage classes are framework agnostic, one simple integration is |
541 | +via the HookData contextmanager. It will record the current hook |
542 | +execution environment (including relation data, config data, etc.), |
543 | +setup a transaction and allow easy access to the changes from |
544 | +previously seen values. One consequence of the integration is the |
545 | +reservation of particular keys ('rels', 'unit', 'env', 'config', |
546 | +'charm_revisions') for their respective values. |
547 | + |
548 | +Here's a fully worked integration example using hookenv.Hooks:: |
549 | + |
550 | + from charmhelper.core import hookenv, unitdata |
551 | + |
552 | + hook_data = unitdata.HookData() |
553 | + db = unitdata.kv() |
554 | + hooks = hookenv.Hooks() |
555 | + |
556 | + @hooks.hook |
557 | + def config_changed(): |
558 | + # Print all changes to configuration from previously seen |
559 | + # values. |
560 | + for changed, (prev, cur) in hook_data.conf.items(): |
561 | + print('config changed', changed, |
562 | + 'previous value', prev, |
563 | + 'current value', cur) |
564 | + |
565 | + # Get some unit specific bookeeping |
566 | + if not db.get('pkg_key'): |
567 | + key = urllib.urlopen('https://example.com/pkg_key').read() |
568 | + db.set('pkg_key', key) |
569 | + |
570 | + # Directly access all charm config as a mapping. |
571 | + conf = db.getrange('config', True) |
572 | + |
573 | + # Directly access all relation data as a mapping |
574 | + rels = db.getrange('rels', True) |
575 | + |
576 | + if __name__ == '__main__': |
577 | + with hook_data(): |
578 | + hook.execute() |
579 | + |
580 | + |
581 | +A more basic integration is via the hook_scope context manager which simply |
582 | +manages transaction scope (and records hook name, and timestamp):: |
583 | + |
584 | + >>> from unitdata import kv |
585 | + >>> db = kv() |
586 | + >>> with db.hook_scope('install'): |
587 | + ... # do work, in transactional scope. |
588 | + ... db.set('x', 1) |
589 | + >>> db.get('x') |
590 | + 1 |
591 | + |
592 | + |
593 | +Usage |
594 | +----- |
595 | + |
596 | +Values are automatically json de/serialized to preserve basic typing |
597 | +and complex data struct capabilities (dicts, lists, ints, booleans, etc). |
598 | + |
599 | +Individual values can be manipulated via get/set:: |
600 | + |
601 | + >>> kv.set('y', True) |
602 | + >>> kv.get('y') |
603 | + True |
604 | + |
605 | + # We can set complex values (dicts, lists) as a single key. |
606 | + >>> kv.set('config', {'a': 1, 'b': True'}) |
607 | + |
608 | + # Also supports returning dictionaries as a record which |
609 | + # provides attribute access. |
610 | + >>> config = kv.get('config', record=True) |
611 | + >>> config.b |
612 | + True |
613 | + |
614 | + |
615 | +Groups of keys can be manipulated with update/getrange:: |
616 | + |
617 | + >>> kv.update({'z': 1, 'y': 2}, prefix="gui.") |
618 | + >>> kv.getrange('gui.', strip=True) |
619 | + {'z': 1, 'y': 2} |
620 | + |
621 | +When updating values, its very helpful to understand which values |
622 | +have actually changed and how have they changed. The storage |
623 | +provides a delta method to provide for this:: |
624 | + |
625 | + >>> data = {'debug': True, 'option': 2} |
626 | + >>> delta = kv.delta(data, 'config.') |
627 | + >>> delta.debug.previous |
628 | + None |
629 | + >>> delta.debug.current |
630 | + True |
631 | + >>> delta |
632 | + {'debug': (None, True), 'option': (None, 2)} |
633 | + |
634 | +Note the delta method does not persist the actual change, it needs to |
635 | +be explicitly saved via 'update' method:: |
636 | + |
637 | + >>> kv.update(data, 'config.') |
638 | + |
639 | +Values modified in the context of a hook scope retain historical values |
640 | +associated to the hookname. |
641 | + |
642 | + >>> with db.hook_scope('config-changed'): |
643 | + ... db.set('x', 42) |
644 | + >>> db.gethistory('x') |
645 | + [(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'), |
646 | + (2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')] |
647 | + |
648 | +""" |
649 | + |
650 | +import collections |
651 | +import contextlib |
652 | +import datetime |
653 | +import json |
654 | +import os |
655 | +import pprint |
656 | +import sqlite3 |
657 | +import sys |
658 | + |
659 | +__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>' |
660 | + |
661 | + |
662 | +class Storage(object): |
663 | + """Simple key value database for local unit state within charms. |
664 | + |
665 | + Modifications are automatically committed at hook exit. That's |
666 | + currently regardless of exit code. |
667 | + |
668 | + To support dicts, lists, integer, floats, and booleans values |
669 | + are automatically json encoded/decoded. |
670 | + """ |
671 | + def __init__(self, path=None): |
672 | + self.db_path = path |
673 | + if path is None: |
674 | + self.db_path = os.path.join( |
675 | + os.environ.get('CHARM_DIR', ''), '.unit-state.db') |
676 | + self.conn = sqlite3.connect('%s' % self.db_path) |
677 | + self.cursor = self.conn.cursor() |
678 | + self.revision = None |
679 | + self._closed = False |
680 | + self._init() |
681 | + |
682 | + def close(self): |
683 | + if self._closed: |
684 | + return |
685 | + self.flush(False) |
686 | + self.cursor.close() |
687 | + self.conn.close() |
688 | + self._closed = True |
689 | + |
690 | + def _scoped_query(self, stmt, params=None): |
691 | + if params is None: |
692 | + params = [] |
693 | + return stmt, params |
694 | + |
695 | + def get(self, key, default=None, record=False): |
696 | + self.cursor.execute( |
697 | + *self._scoped_query( |
698 | + 'select data from kv where key=?', [key])) |
699 | + result = self.cursor.fetchone() |
700 | + if not result: |
701 | + return default |
702 | + if record: |
703 | + return Record(json.loads(result[0])) |
704 | + return json.loads(result[0]) |
705 | + |
706 | + def getrange(self, key_prefix, strip=False): |
707 | + stmt = "select key, data from kv where key like '%s%%'" % key_prefix |
708 | + self.cursor.execute(*self._scoped_query(stmt)) |
709 | + result = self.cursor.fetchall() |
710 | + |
711 | + if not result: |
712 | + return None |
713 | + if not strip: |
714 | + key_prefix = '' |
715 | + return dict([ |
716 | + (k[len(key_prefix):], json.loads(v)) for k, v in result]) |
717 | + |
718 | + def update(self, mapping, prefix=""): |
719 | + for k, v in mapping.items(): |
720 | + self.set("%s%s" % (prefix, k), v) |
721 | + |
722 | + def unset(self, key): |
723 | + self.cursor.execute('delete from kv where key=?', [key]) |
724 | + if self.revision and self.cursor.rowcount: |
725 | + self.cursor.execute( |
726 | + 'insert into kv_revisions values (?, ?, ?)', |
727 | + [key, self.revision, json.dumps('DELETED')]) |
728 | + |
729 | + def set(self, key, value): |
730 | + serialized = json.dumps(value) |
731 | + |
732 | + self.cursor.execute( |
733 | + 'select data from kv where key=?', [key]) |
734 | + exists = self.cursor.fetchone() |
735 | + |
736 | + # Skip mutations to the same value |
737 | + if exists: |
738 | + if exists[0] == serialized: |
739 | + return value |
740 | + |
741 | + if not exists: |
742 | + self.cursor.execute( |
743 | + 'insert into kv (key, data) values (?, ?)', |
744 | + (key, serialized)) |
745 | + else: |
746 | + self.cursor.execute(''' |
747 | + update kv |
748 | + set data = ? |
749 | + where key = ?''', [serialized, key]) |
750 | + |
751 | + # Save |
752 | + if not self.revision: |
753 | + return value |
754 | + |
755 | + self.cursor.execute( |
756 | + 'select 1 from kv_revisions where key=? and revision=?', |
757 | + [key, self.revision]) |
758 | + exists = self.cursor.fetchone() |
759 | + |
760 | + if not exists: |
761 | + self.cursor.execute( |
762 | + '''insert into kv_revisions ( |
763 | + revision, key, data) values (?, ?, ?)''', |
764 | + (self.revision, key, serialized)) |
765 | + else: |
766 | + self.cursor.execute( |
767 | + ''' |
768 | + update kv_revisions |
769 | + set data = ? |
770 | + where key = ? |
771 | + and revision = ?''', |
772 | + [serialized, key, self.revision]) |
773 | + |
774 | + return value |
775 | + |
776 | + def delta(self, mapping, prefix): |
777 | + """ |
778 | + return a delta containing values that have changed. |
779 | + """ |
780 | + previous = self.getrange(prefix, strip=True) |
781 | + if not previous: |
782 | + pk = set() |
783 | + else: |
784 | + pk = set(previous.keys()) |
785 | + ck = set(mapping.keys()) |
786 | + delta = DeltaSet() |
787 | + |
788 | + # added |
789 | + for k in ck.difference(pk): |
790 | + delta[k] = Delta(None, mapping[k]) |
791 | + |
792 | + # removed |
793 | + for k in pk.difference(ck): |
794 | + delta[k] = Delta(previous[k], None) |
795 | + |
796 | + # changed |
797 | + for k in pk.intersection(ck): |
798 | + c = mapping[k] |
799 | + p = previous[k] |
800 | + if c != p: |
801 | + delta[k] = Delta(p, c) |
802 | + |
803 | + return delta |
804 | + |
805 | + @contextlib.contextmanager |
806 | + def hook_scope(self, name=""): |
807 | + """Scope all future interactions to the current hook execution |
808 | + revision.""" |
809 | + assert not self.revision |
810 | + self.cursor.execute( |
811 | + 'insert into hooks (hook, date) values (?, ?)', |
812 | + (name or sys.argv[0], |
813 | + datetime.datetime.utcnow().isoformat())) |
814 | + self.revision = self.cursor.lastrowid |
815 | + try: |
816 | + yield self.revision |
817 | + self.revision = None |
818 | + except: |
819 | + self.flush(False) |
820 | + self.revision = None |
821 | + raise |
822 | + else: |
823 | + self.flush() |
824 | + |
825 | + def flush(self, save=True): |
826 | + if save: |
827 | + self.conn.commit() |
828 | + elif self._closed: |
829 | + return |
830 | + else: |
831 | + self.conn.rollback() |
832 | + |
833 | + def _init(self): |
834 | + self.cursor.execute(''' |
835 | + create table if not exists kv ( |
836 | + key text, |
837 | + data text, |
838 | + primary key (key) |
839 | + )''') |
840 | + self.cursor.execute(''' |
841 | + create table if not exists kv_revisions ( |
842 | + key text, |
843 | + revision integer, |
844 | + data text, |
845 | + primary key (key, revision) |
846 | + )''') |
847 | + self.cursor.execute(''' |
848 | + create table if not exists hooks ( |
849 | + version integer primary key autoincrement, |
850 | + hook text, |
851 | + date text |
852 | + )''') |
853 | + self.conn.commit() |
854 | + |
855 | + def gethistory(self, key, deserialize=False): |
856 | + self.cursor.execute( |
857 | + ''' |
858 | + select kv.revision, kv.key, kv.data, h.hook, h.date |
859 | + from kv_revisions kv, |
860 | + hooks h |
861 | + where kv.key=? |
862 | + and kv.revision = h.version |
863 | + ''', [key]) |
864 | + if deserialize is False: |
865 | + return self.cursor.fetchall() |
866 | + return map(_parse_history, self.cursor.fetchall()) |
867 | + |
868 | + def debug(self, fh=sys.stderr): |
869 | + self.cursor.execute('select * from kv') |
870 | + pprint.pprint(self.cursor.fetchall(), stream=fh) |
871 | + self.cursor.execute('select * from kv_revisions') |
872 | + pprint.pprint(self.cursor.fetchall(), stream=fh) |
873 | + |
874 | + |
875 | +def _parse_history(d): |
876 | + return (d[0], d[1], json.loads(d[2]), d[3], |
877 | + datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f")) |
878 | + |
879 | + |
880 | +class HookData(object): |
881 | + """Simple integration for existing hook exec frameworks. |
882 | + |
883 | + Records all unit information, and stores deltas for processing |
884 | + by the hook. |
885 | + |
886 | + Sample:: |
887 | + |
888 | + from charmhelper.core import hookenv, unitdata |
889 | + |
890 | + changes = unitdata.HookData() |
891 | + db = unitdata.kv() |
892 | + hooks = hookenv.Hooks() |
893 | + |
894 | + @hooks.hook |
895 | + def config_changed(): |
896 | + # View all changes to configuration |
897 | + for changed, (prev, cur) in changes.conf.items(): |
898 | + print('config changed', changed, |
899 | + 'previous value', prev, |
900 | + 'current value', cur) |
901 | + |
902 | + # Get some unit specific bookeeping |
903 | + if not db.get('pkg_key'): |
904 | + key = urllib.urlopen('https://example.com/pkg_key').read() |
905 | + db.set('pkg_key', key) |
906 | + |
907 | + if __name__ == '__main__': |
908 | + with changes(): |
909 | + hook.execute() |
910 | + |
911 | + """ |
912 | + def __init__(self): |
913 | + self.kv = kv() |
914 | + self.conf = None |
915 | + self.rels = None |
916 | + |
917 | + @contextlib.contextmanager |
918 | + def __call__(self): |
919 | + from charmhelpers.core import hookenv |
920 | + hook_name = hookenv.hook_name() |
921 | + |
922 | + with self.kv.hook_scope(hook_name): |
923 | + self._record_charm_version(hookenv.charm_dir()) |
924 | + delta_config, delta_relation = self._record_hook(hookenv) |
925 | + yield self.kv, delta_config, delta_relation |
926 | + |
927 | + def _record_charm_version(self, charm_dir): |
928 | + # Record revisions.. charm revisions are meaningless |
929 | + # to charm authors as they don't control the revision. |
930 | + # so logic dependnent on revision is not particularly |
931 | + # useful, however it is useful for debugging analysis. |
932 | + charm_rev = open( |
933 | + os.path.join(charm_dir, 'revision')).read().strip() |
934 | + charm_rev = charm_rev or '0' |
935 | + revs = self.kv.get('charm_revisions', []) |
936 | + if charm_rev not in revs: |
937 | + revs.append(charm_rev.strip() or '0') |
938 | + self.kv.set('charm_revisions', revs) |
939 | + |
940 | + def _record_hook(self, hookenv): |
941 | + data = hookenv.execution_environment() |
942 | + self.conf = conf_delta = self.kv.delta(data['conf'], 'config') |
943 | + self.rels = rels_delta = self.kv.delta(data['rels'], 'rels') |
944 | + self.kv.set('env', data['env']) |
945 | + self.kv.set('unit', data['unit']) |
946 | + self.kv.set('relid', data.get('relid')) |
947 | + return conf_delta, rels_delta |
948 | + |
949 | + |
950 | +class Record(dict): |
951 | + |
952 | + __slots__ = () |
953 | + |
954 | + def __getattr__(self, k): |
955 | + if k in self: |
956 | + return self[k] |
957 | + raise AttributeError(k) |
958 | + |
959 | + |
960 | +class DeltaSet(Record): |
961 | + |
962 | + __slots__ = () |
963 | + |
964 | + |
965 | +Delta = collections.namedtuple('Delta', ['previous', 'current']) |
966 | + |
967 | + |
968 | +_KV = None |
969 | + |
970 | + |
971 | +def kv(): |
972 | + global _KV |
973 | + if _KV is None: |
974 | + _KV = Storage() |
975 | + return _KV |
976 | |
977 | === added symlink 'hooks/cluster-relation-changed' |
978 | === target is u'memcached_hooks.py' |
979 | === added symlink 'hooks/cluster-relation-joined' |
980 | === target is u'memcached_hooks.py' |
981 | === modified file 'hooks/memcached_hooks.py' |
982 | --- hooks/memcached_hooks.py 2014-12-19 18:24:29 +0000 |
983 | +++ hooks/memcached_hooks.py 2015-02-18 02:12:32 +0000 |
984 | @@ -1,11 +1,10 @@ |
985 | #!/usr/bin/env python |
986 | -__author__ = 'Felipe Reyes <felipe.reyes@canonical.com>' |
987 | - |
988 | import re |
989 | import os |
990 | import shutil |
991 | import subprocess |
992 | import sys |
993 | +import time |
994 | |
995 | from charmhelpers.core.hookenv import ( |
996 | config, |
997 | @@ -25,10 +24,19 @@ |
998 | service_start, |
999 | service_stop, |
1000 | ) |
1001 | -from charmhelpers.fetch import apt_install, apt_update |
1002 | + |
1003 | +from charmhelpers.contrib.hahelpers.cluster import ( |
1004 | + oldest_peer, |
1005 | + peer_units, |
1006 | +) |
1007 | + |
1008 | +from charmhelpers.fetch import apt_install, apt_update, add_source |
1009 | from charmhelpers.contrib.network import ufw |
1010 | + |
1011 | import memcached_utils |
1012 | +import replication |
1013 | |
1014 | +__author__ = 'Felipe Reyes <felipe.reyes@canonical.com>' |
1015 | |
1016 | DOT = os.path.dirname(os.path.abspath(__file__)) |
1017 | ETC_DEFAULT_MEMCACHED = '/etc/default/memcached' |
1018 | @@ -38,6 +46,7 @@ |
1019 | MEMCACHE_NAGIOS_PLUGIN = '/usr/local/lib/nagios/plugins/check_memcache.py' |
1020 | RESTART_MAP = {ETC_MEMCACHED_CONF: ['memcached'], |
1021 | ETC_MUNIN_NODE_CONF: ['munin-node']} |
1022 | + |
1023 | hooks = Hooks() |
1024 | |
1025 | |
1026 | @@ -47,6 +56,11 @@ |
1027 | |
1028 | @hooks.hook('install') |
1029 | def install(): |
1030 | + enable_repcached = config('repcached') |
1031 | + |
1032 | + if enable_repcached: |
1033 | + add_source(config('repcached_origin')) |
1034 | + |
1035 | apt_update(fatal=True) |
1036 | apt_install(["memcached", "python-cheetah", "python-memcache"], fatal=True) |
1037 | |
1038 | @@ -54,6 +68,10 @@ |
1039 | f.write('ENABLE_MEMCACHED=yes\n') |
1040 | |
1041 | ufw.enable() |
1042 | + |
1043 | + if enable_repcached: |
1044 | + ufw.service(config('repcached_port'), 'open') |
1045 | + |
1046 | ufw.service('ssh', 'open') |
1047 | |
1048 | |
1049 | @@ -67,6 +85,61 @@ |
1050 | service_stop('memcached') |
1051 | |
1052 | |
1053 | +@hooks.hook('cluster-relation-joined') |
1054 | +def cluster_relation_joined(): |
1055 | + |
1056 | + if not config('repcached'): |
1057 | + return config_changed() |
1058 | + |
1059 | + peers = peer_units() |
1060 | + if peers and not oldest_peer(peers): |
1061 | + log("Delaying operation to oldest peer", level='INFO') |
1062 | + return |
1063 | + |
1064 | + replica = replication.get_repcached_replica() |
1065 | + if not replica: |
1066 | + replica = replication.set_repcached_replica() |
1067 | + |
1068 | + master, replica = replica |
1069 | + log("Setting replication with peer unit: %s" % replica, level="INFO") |
1070 | + config_changed(replica=replica) |
1071 | + |
1072 | + |
1073 | +@hooks.hook('cluster-relation-changed') |
1074 | +def cluster_relation_changed(): |
1075 | + |
1076 | + if not config('repcached'): |
1077 | + return config_changed() |
1078 | + |
1079 | + peers = peer_units() |
1080 | + if peers and oldest_peer(peers): |
1081 | + log("Delaying operation to secondary peer", level='INFO') |
1082 | + return |
1083 | + |
1084 | + retries = 0 |
1085 | + while retries < 3: |
1086 | + log('Waiting for master memcached node to become available', |
1087 | + level='INFO') |
1088 | + secondary = relation_get('replica') |
1089 | + try: |
1090 | + if secondary == unit_get('private-address'): |
1091 | + replication.store_replica(relation_get('master'), secondary) |
1092 | + return config_changed(replica=relation_get('master')) |
1093 | + elif secondary is None: |
1094 | + raise AttributeError() |
1095 | + else: |
1096 | + msg = """This unit will be marked as failed because |
1097 | + Repcached replication only can take place between two units. |
1098 | + If you want to disable this set 'juju set repcached=false'""" |
1099 | + log(msg, level='WARN') |
1100 | + raise Exception(msg) |
1101 | + except AttributeError: |
1102 | + log("Master not yet available, waiting for %d secs" % |
1103 | + replication.REPCACHED_MASTER_WAIT) |
1104 | + time.sleep(replication.REPCACHED_MASTER_WAIT) |
1105 | + retries += 1 |
1106 | + |
1107 | + |
1108 | @hooks.hook('cache-relation-joined') |
1109 | def cache_relation_joined(): |
1110 | |
1111 | @@ -92,7 +165,7 @@ |
1112 | |
1113 | @hooks.hook('config-changed') |
1114 | @restart_on_change(RESTART_MAP) |
1115 | -def config_changed(): |
1116 | +def config_changed(replica=None): |
1117 | mem_size = config('size') |
1118 | |
1119 | if mem_size == 0: |
1120 | @@ -100,9 +173,38 @@ |
1121 | mem_size = int(re.findall(r'\d+', output.split('\n')[2])[1]) |
1122 | mem_size = int(mem_size * 0.9) |
1123 | |
1124 | + if config('repcached'): |
1125 | + # If repcached was enabled after install, we need |
1126 | + # to make sure to install the memcached package with replication |
1127 | + # enabled. |
1128 | + if not memcached_utils.dpkg_info_contains('memcached', |
1129 | + 'version', |
1130 | + 'repcache'): |
1131 | + add_source(config('repcached_origin')) |
1132 | + apt_update(fatal=True) |
1133 | + apt_install(["memcached"], fatal=True) |
1134 | + |
1135 | + if not replica: |
1136 | + replica = replication.get_current_replica() |
1137 | + |
1138 | + elif ( |
1139 | + config('repcached') and |
1140 | + os.path.exists(replication.REPCACHED_REPLICA_FILE) |
1141 | + ): |
1142 | + log("Replication has been disabled, removing from this unit", |
1143 | + level='INFO') |
1144 | + ufw.service(config('repcached_port'), 'close') |
1145 | + os.remove(replication.REPCACHED_REPLICA_FILE) |
1146 | + replica = None |
1147 | + |
1148 | configs = {'mem_size': mem_size, |
1149 | + 'replica': replica, |
1150 | 'large_pages_enabled': False} |
1151 | |
1152 | + if replica: |
1153 | + configs['repcached_port'] = config('repcached_port') |
1154 | + ufw.service(config('repcached_port'), 'open') |
1155 | + |
1156 | for key in ['request-limit', 'min-item-size', 'slab-page-size', 'threads', |
1157 | 'disable-auto-cleanup', 'disable-cas', 'factor', |
1158 | 'connection-limit', 'tcp-port', 'udp-port']: |
1159 | |
1160 | === modified file 'hooks/memcached_utils.py' |
1161 | --- hooks/memcached_utils.py 2014-12-04 19:57:52 +0000 |
1162 | +++ hooks/memcached_utils.py 2015-02-18 02:12:32 +0000 |
1163 | @@ -1,11 +1,13 @@ |
1164 | -__author__ = 'Felipe Reyes <felipe.reyes@canonical.com>' |
1165 | - |
1166 | from charmhelpers.contrib.network import ufw |
1167 | from charmhelpers.core.hookenv import ( |
1168 | config, |
1169 | log, |
1170 | ) |
1171 | |
1172 | +import subprocess |
1173 | + |
1174 | +__author__ = 'Felipe Reyes <felipe.reyes@canonical.com>' |
1175 | + |
1176 | |
1177 | def munin_format_ip(ip): |
1178 | return "^{}$".format(ip.replace('.', '\\.')) |
1179 | @@ -25,3 +27,16 @@ |
1180 | |
1181 | if config('udp-port') > 0: |
1182 | ufw.revoke_access(address, port=str(config('udp-port')), proto='udp') |
1183 | + |
1184 | + |
1185 | +def dpkg_info_contains(package, key, value): |
1186 | + info = {} |
1187 | + dpkg = subprocess.check_output( |
1188 | + ['dpkg', '-s', package]).splitlines() |
1189 | + |
1190 | + for entry in dpkg: |
1191 | + entry = entry.split(':') |
1192 | + if len(entry) > 1: |
1193 | + info.update({entry[0].lower(): entry[1]}) |
1194 | + |
1195 | + return info.get(key, -1).find(value) != -1 |
1196 | |
1197 | === added file 'hooks/replication.py' |
1198 | --- hooks/replication.py 1970-01-01 00:00:00 +0000 |
1199 | +++ hooks/replication.py 2015-02-18 02:12:32 +0000 |
1200 | @@ -0,0 +1,84 @@ |
1201 | +from charmhelpers.core.hookenv import ( |
1202 | + log, |
1203 | + relation_id, |
1204 | + relation_set, |
1205 | + unit_get, |
1206 | +) |
1207 | + |
1208 | +from charmhelpers.contrib.hahelpers.cluster import ( |
1209 | + oldest_peer, |
1210 | + peer_units, |
1211 | + peer_ips, |
1212 | +) |
1213 | + |
1214 | +REPCACHED_REPLICA_FILE = "/var/run/repcached_replica" |
1215 | +REPCACHED_MASTER_WAIT = 10 |
1216 | + |
1217 | + |
1218 | +def store_replica(master, replica): |
1219 | + with open(REPCACHED_REPLICA_FILE, 'w+') as fd: |
1220 | + fd.write("%s,%s" % (master, replica)) |
1221 | + |
1222 | + |
1223 | +def get_current_replica(): |
1224 | + replica = get_repcached_replica() |
1225 | + |
1226 | + if replica: |
1227 | + master, secondary = replica |
1228 | + ip_addr = unit_get('private-address') |
1229 | + if ip_addr == master: |
1230 | + replica = secondary |
1231 | + elif ip_addr == secondary: |
1232 | + replica = master |
1233 | + else: |
1234 | + peers = peer_units() |
1235 | + |
1236 | + if len(peers) > 1: |
1237 | + msg = """Cannot configure replication between more than 2 units, |
1238 | + all the units are going to be setup as standalone. Please |
1239 | + remove the remaining units with 'juju remove-unit', and |
1240 | + then set 'juju set memcached repcached=true' again""" |
1241 | + log(msg, level='WARN') |
1242 | + else: |
1243 | + try: |
1244 | + peer_unit = peer_ips().values()[0] |
1245 | + |
1246 | + if oldest_peer(peers): |
1247 | + master, secondary = (unit_get('private-address'), |
1248 | + peer_unit) |
1249 | + replica = secondary |
1250 | + else: |
1251 | + master, secondary = (peer_unit, |
1252 | + unit_get('private-address')) |
1253 | + replica = master |
1254 | + |
1255 | + store_replica(master, secondary) |
1256 | + |
1257 | + except IndexError: |
1258 | + replica = None |
1259 | + |
1260 | + log("Current replica value: %s" % replica, level="INFO") |
1261 | + return replica |
1262 | + |
1263 | + |
1264 | +def get_repcached_replica(): |
1265 | + try: |
1266 | + with open(REPCACHED_REPLICA_FILE, 'r') as fd: |
1267 | + return tuple(fd.read().split(',')) |
1268 | + except: |
1269 | + return None |
1270 | + |
1271 | + |
1272 | +def set_repcached_replica(): |
1273 | + replica = peer_ips().values()[0] |
1274 | + log("Setting replica unit: %s" % replica) |
1275 | + |
1276 | + master = unit_get('private-address') |
1277 | + relation_set(relation_id(), { |
1278 | + 'master': master, |
1279 | + 'replica': replica, |
1280 | + }) |
1281 | + |
1282 | + with open(REPCACHED_REPLICA_FILE, 'w+') as fd: |
1283 | + fd.write("%s,%s" % (master, replica)) |
1284 | + return (master, replica) |
1285 | |
1286 | === modified file 'metadata.yaml' |
1287 | --- metadata.yaml 2014-12-09 17:19:45 +0000 |
1288 | +++ metadata.yaml 2015-02-18 02:12:32 +0000 |
1289 | @@ -1,7 +1,7 @@ |
1290 | name: memcached |
1291 | summary: "A high-performance memory object caching system" |
1292 | maintainer: Felipe Reyes <felipe.reyes@canonical.com> |
1293 | -description: |
1294 | +description: |
1295 | memcached optimizes specific high-load serving applications that are designed |
1296 | to take advantage of its versatile no-locking memory access system. Clients |
1297 | are available in several different programming languages, to suit the needs |
1298 | @@ -17,3 +17,6 @@ |
1299 | nrpe-external-master: |
1300 | interface: nrpe-external-master |
1301 | scope: container |
1302 | +peers: |
1303 | + cluster: |
1304 | + interface: memcached-replication |
1305 | \ No newline at end of file |
1306 | |
1307 | === modified file 'revision' |
1308 | --- revision 2013-04-18 16:23:01 +0000 |
1309 | +++ revision 2015-02-18 02:12:32 +0000 |
1310 | @@ -1,1 +1,1 @@ |
1311 | -28 |
1312 | +69 |
1313 | |
1314 | === modified file 'templates/memcached.conf' |
1315 | --- templates/memcached.conf 2014-12-01 20:53:36 +0000 |
1316 | +++ templates/memcached.conf 2015-02-18 02:12:32 +0000 |
1317 | @@ -8,7 +8,7 @@ |
1318 | # memcached default config file |
1319 | # 2003 - Jay Bonci <jaybonci@debian.org> |
1320 | # This configuration file is read by the start-memcached script provided as |
1321 | -# part of the Debian GNU/Linux distribution. |
1322 | +# part of the Debian GNU/Linux distribution. |
1323 | |
1324 | # Run memcached as a daemon. This command is implied, and is not needed for the |
1325 | # daemon to run. See the README.Debian that comes with this package for more |
1326 | @@ -60,3 +60,5 @@ |
1327 | {% if disable_auto_cleanup %}-M{% endif %} |
1328 | {% if disable_cas %}-C{% endif %} |
1329 | {% if factor != -1.0 %}-f {{factor}}{% endif %} |
1330 | +{% if replica %}-x {{replica}}{% endif %} |
1331 | +{% if repcached_port %}-X {{repcached_port}}{%endif%} |
1332 | \ No newline at end of file |
1333 | |
1334 | === modified file 'tests/10_deploy_test.py' |
1335 | --- tests/10_deploy_test.py 2014-12-09 17:19:45 +0000 |
1336 | +++ tests/10_deploy_test.py 2015-02-18 02:12:32 +0000 |
1337 | @@ -19,7 +19,7 @@ |
1338 | |
1339 | # Create a configuration dictionary for custom memcached values. |
1340 | configuration = {'size': 512, 'connection-limit': 128, 'factor': 1.10, |
1341 | - 'tcp-port': 11212, 'udp-port': 11213} |
1342 | + 'tcp-port': 11214, 'udp-port': 11213} |
1343 | d.configure('memcached', configuration) |
1344 | # Expose memcached so it is visible to the tests. |
1345 | d.expose('memcached') |
1346 | |
1347 | === added file 'tests/20_deploy_replication_test.py' |
1348 | --- tests/20_deploy_replication_test.py 1970-01-01 00:00:00 +0000 |
1349 | +++ tests/20_deploy_replication_test.py 2015-02-18 02:12:32 +0000 |
1350 | @@ -0,0 +1,117 @@ |
1351 | +#!/usr/bin/python3 |
1352 | + |
1353 | +import amulet |
1354 | +import telnetlib |
1355 | +import time |
1356 | + |
1357 | + |
1358 | +def check_config(a, b): |
1359 | + config_string = a.file_contents('/etc/memcached.conf') |
1360 | + # Parse the configuration file for the values sent in the deployment. |
1361 | + for line in config_string.splitlines(): |
1362 | + print(line) |
1363 | + if line.startswith('-x'): |
1364 | + o = line.split()[1] |
1365 | + break |
1366 | + |
1367 | + if o != b.info['public-address']: |
1368 | + amulet.raise_status(amulet.FAIL, msg='Incorrect replica address') |
1369 | + |
1370 | + |
1371 | +def check_replication(a, b, port): |
1372 | + date_time = time.strftime("%F %r") |
1373 | + string = 'memcached test %s' % date_time |
1374 | + |
1375 | + try: |
1376 | + tn = telnetlib.Telnet(a.info['public-address'], port) |
1377 | + command = 'set greeting 1 0 %d' % len(string) |
1378 | + tn.write(command.encode() + b'\r\n') |
1379 | + tn.write(string.encode() + b'\r\n') |
1380 | + response = tn.read_until(b'STORED', 2) |
1381 | + tn.close() |
1382 | + |
1383 | + tn = telnetlib.Telnet(b.info['public-address'], port) |
1384 | + tn.write(b'get greeting\r\n') |
1385 | + response = tn.read_until(b'END', 2) |
1386 | + response = response.decode() |
1387 | + index = response.find(string) |
1388 | + |
1389 | + if index != -1: |
1390 | + print('Found %s in the greeting response.' % string) |
1391 | + else: |
1392 | + print(response) |
1393 | + message = 'Did not find %s in the greeting from memcached' % string |
1394 | + amulet.raise_status(amulet.FAIL, msg=message) |
1395 | + tn.write(b'quit\n') |
1396 | + |
1397 | + except Exception: |
1398 | + message = 'An error occurred communicating with memcached over telnet' |
1399 | + amulet.raise_status(amulet.FAIL, msg=message) |
1400 | + finally: |
1401 | + tn.close() |
1402 | + |
1403 | + |
1404 | +seconds = 1200 |
1405 | +d = amulet.Deployment(series="trusty") |
1406 | +d.add('memcached', units=2) |
1407 | + |
1408 | +configuration = { |
1409 | + 'repcached': True, |
1410 | + 'size': 512, |
1411 | + 'connection-limit': 128, |
1412 | + 'factor': 1.10, |
1413 | + 'tcp-port': 11211, |
1414 | + 'udp-port': 11213, |
1415 | +} |
1416 | + |
1417 | +d.configure('memcached', configuration) |
1418 | +d.expose('memcached') |
1419 | + |
1420 | +try: |
1421 | + d.setup(timeout=seconds) |
1422 | + d.sentry.wait(seconds) |
1423 | +except amulet.helpers.TimeoutError: |
1424 | + message = 'The environment did not setup in %d seconds.' % seconds |
1425 | + amulet.raise_status(amulet.SKIP, msg=message) |
1426 | +except: |
1427 | + raise |
1428 | + |
1429 | +units = (d.sentry['memcached/0'], d.sentry['memcached/1']) |
1430 | +memcached_port = configuration.get('tcp-port') |
1431 | + |
1432 | +for memcached_unit in units: |
1433 | + command = 'service memcached status' |
1434 | + output, code = memcached_unit.run(command) |
1435 | + |
1436 | + # open memcache to be able to connect from this machine |
1437 | + memcached_unit.run('ufw allow {}'.format(memcached_port)) |
1438 | + |
1439 | + if code != 0: |
1440 | + message = 'The ' + command + ' returned %d.' % code |
1441 | + print(output) |
1442 | + amulet.raise_status(amulet.FAIL, msg=message) |
1443 | + else: |
1444 | + message = 'The memcached service is running.' |
1445 | + print(output) |
1446 | + print(message) |
1447 | + |
1448 | + memcached_address = memcached_unit.info['public-address'] |
1449 | + try: |
1450 | + telnetlib.Telnet(memcached_address, memcached_port) |
1451 | + except TimeoutError: # noqa this exception only available in py3 |
1452 | + amulet.raise_status(amulet.FAIL, msg="Cannot connect to port %s" % |
1453 | + memcached_port) |
1454 | + |
1455 | +primary, secondary = units |
1456 | + |
1457 | +# if primary points to secondary |
1458 | +check_config(primary, secondary) |
1459 | + |
1460 | +# if secondary points to primary |
1461 | +check_config(secondary, primary) |
1462 | + |
1463 | +# if replication primary -> secondary works |
1464 | +check_replication(primary, secondary, memcached_port) |
1465 | + |
1466 | +# if replication secondary -> primary works |
1467 | +check_replication(secondary, primary, memcached_port) |
1468 | |
1469 | === modified file 'unit_tests/test_memcached_hooks.py' |
1470 | --- unit_tests/test_memcached_hooks.py 2015-02-04 12:16:33 +0000 |
1471 | +++ unit_tests/test_memcached_hooks.py 2015-02-18 02:12:32 +0000 |
1472 | @@ -1,5 +1,3 @@ |
1473 | -__author__ = 'Felipe Reyes <felipe.reyes@canonical.com>' |
1474 | - |
1475 | import mock |
1476 | import os |
1477 | import shutil |
1478 | @@ -7,6 +5,8 @@ |
1479 | from test_utils import CharmTestCase |
1480 | import memcached_hooks |
1481 | |
1482 | +__author__ = 'Felipe Reyes <felipe.reyes@canonical.com>' |
1483 | + |
1484 | |
1485 | DOT = os.path.dirname(os.path.abspath(__file__)) |
1486 | TO_PATCH = [ |
1487 | @@ -20,6 +20,8 @@ |
1488 | 'unit_get', |
1489 | 'config', |
1490 | 'log', |
1491 | + 'oldest_peer', |
1492 | + 'peer_units', |
1493 | ] |
1494 | FREE_MEM_SMALL = """ total used free shared \ |
1495 | buffers cached |
1496 | @@ -52,9 +54,21 @@ |
1497 | super(TestMemcachedHooks, self).tearDown() |
1498 | shutil.rmtree(self.tmpdir, ignore_errors=True) |
1499 | |
1500 | + @mock.patch('memcached_hooks.add_source') |
1501 | @mock.patch('subprocess.check_output') |
1502 | - def test_install(self, check_output): |
1503 | + def test_install(self, check_output, add_source): |
1504 | + configs = { |
1505 | + 'repcached': True, |
1506 | + 'repcached_origin': 'ppa:niedbalski/memcached-repcached' |
1507 | + } |
1508 | + |
1509 | + def f(c): |
1510 | + return configs.get(c, None) |
1511 | + |
1512 | + self.config.side_effect = f |
1513 | + |
1514 | memcached_hooks.install() |
1515 | + add_source.assert_called_with(configs['repcached_origin']) |
1516 | |
1517 | self.apt_update.assert_called_with(fatal=True) |
1518 | self.apt_install.assert_called_with(["memcached", "python-cheetah", |
1519 | @@ -248,6 +262,7 @@ |
1520 | @mock.patch('os.fchown') |
1521 | @mock.patch('os.chown') |
1522 | @mock.patch('memcached_utils.log') |
1523 | + @mock.patch('memcached_utils.dpkg_info_contains') |
1524 | @mock.patch('subprocess.Popen') |
1525 | @mock.patch('memcached_utils.config') |
1526 | @mock.patch('subprocess.check_output') |
1527 | @@ -255,17 +270,227 @@ |
1528 | @mock.patch('charmhelpers.contrib.network.ufw.service') |
1529 | @mock.patch('charmhelpers.core.hookenv.charm_dir') |
1530 | @mock.patch('charmhelpers.core.host.log') |
1531 | - def test_upgrade_charm(self, log, charm_dir, service, enable, check_output, |
1532 | - config, popen, *args): |
1533 | + @mock.patch('memcached_hooks.add_source') |
1534 | + @mock.patch('memcached_hooks.config_changed') |
1535 | + def test_upgrade_charm(self, config_changed, |
1536 | + add_source, log, charm_dir, service, enable, |
1537 | + check_output, config, popen, dpkg, *args): |
1538 | p = mock.Mock() |
1539 | p.configure_mock(**{'communicate.return_value': ('stdout', 'stderr'), |
1540 | 'returncode': 0}) |
1541 | popen.return_value = p |
1542 | + dpkg.return_value = True |
1543 | |
1544 | charm_dir.return_value = os.path.join(DOT, '..') |
1545 | config.return_value = 12111 |
1546 | + |
1547 | + configs = { |
1548 | + 'repcached': True, |
1549 | + 'repcached_origin': 'ppa:niedbalski/memcached-repcached' |
1550 | + } |
1551 | + |
1552 | + def f(c): |
1553 | + return configs.get(c, None) |
1554 | + |
1555 | + self.config.side_effect = f |
1556 | + |
1557 | memcached_hooks.upgrade_charm() |
1558 | + |
1559 | + add_source.assert_called_with(configs['repcached_origin']) |
1560 | self.apt_install.assert_any_call(['memcached', 'python-cheetah', |
1561 | 'python-memcache'], fatal=True) |
1562 | enable.assert_called_with() |
1563 | service.assert_called_with('ssh', 'open') |
1564 | + |
1565 | + @mock.patch('replication.store_replica') |
1566 | + @mock.patch('memcached_hooks.replication.get_repcached_replica') |
1567 | + @mock.patch('replication.set_repcached_replica') |
1568 | + @mock.patch('memcached_hooks.config_changed') |
1569 | + def test_cluster_relation_joined_secondary(self, config_changed, set_rep, |
1570 | + get_rep, store): |
1571 | + configs = {'repcached': True} |
1572 | + |
1573 | + def f(c): |
1574 | + return configs.get(c, None) |
1575 | + |
1576 | + get_rep.return_value = ('10.0.0.1', '10.0.0.2') |
1577 | + |
1578 | + self.config.side_effect = f |
1579 | + self.oldest_peer.return_value = False |
1580 | + memcached_hooks.cluster_relation_joined() |
1581 | + config_changed.assert_not_called() |
1582 | + |
1583 | + @mock.patch('replication.get_repcached_replica') |
1584 | + @mock.patch('replication.set_repcached_replica') |
1585 | + @mock.patch('memcached_hooks.config_changed') |
1586 | + def test_cluster_relation_joined_master(self, config_changed, set_rep, |
1587 | + get_rep): |
1588 | + configs = {'repcached': True} |
1589 | + |
1590 | + def f(c): |
1591 | + return configs.get(c, None) |
1592 | + |
1593 | + self.config.side_effect = f |
1594 | + self.peer_units.return_value = [0, 0] |
1595 | + self.oldest_peer.return_value = True |
1596 | + get_rep.return_value = ('10.0.0.1', '10.0.0.2') |
1597 | + memcached_hooks.cluster_relation_joined() |
1598 | + |
1599 | + config_changed.assert_called_with( |
1600 | + replica='10.0.0.2') |
1601 | + |
1602 | + @mock.patch('memcached_hooks.config_changed') |
1603 | + def test_cluster_relation_changed_master(self, config_changed): |
1604 | + configs = {'repcached': True} |
1605 | + |
1606 | + def f(c): |
1607 | + return configs.get(c, None) |
1608 | + |
1609 | + self.config.side_effect = f |
1610 | + self.peer_units.return_value = [0, 0] |
1611 | + self.oldest_peer.return_value = True |
1612 | + |
1613 | + memcached_hooks.cluster_relation_changed() |
1614 | + config_changed.assert_not_called() |
1615 | + |
1616 | + @mock.patch('replication.store_replica') |
1617 | + @mock.patch('memcached_hooks.config_changed') |
1618 | + def test_cluster_relation_changed_secondary(self, config_changed, store): |
1619 | + configs = {'repcached': True} |
1620 | + |
1621 | + def f(c): |
1622 | + return configs.get(c, None) |
1623 | + |
1624 | + self.config.side_effect = f |
1625 | + |
1626 | + relations = { |
1627 | + 'replica': '10.0.0.2', |
1628 | + 'master': '10.0.0.1', |
1629 | + } |
1630 | + |
1631 | + def r(k): |
1632 | + return relations.get(k, None) |
1633 | + |
1634 | + self.relation_get.side_effect = r |
1635 | + self.unit_get.return_value = '10.0.0.2' |
1636 | + |
1637 | + self.peer_units.return_value = [0, 0] |
1638 | + self.oldest_peer.return_value = False |
1639 | + |
1640 | + memcached_hooks.cluster_relation_changed() |
1641 | + config_changed.assert_called_with( |
1642 | + replica=relations.get('master')) |
1643 | + |
1644 | + @mock.patch('memcached_hooks.config_changed') |
1645 | + def test_cluster_relation_changed_secondary_fail(self, config_changed): |
1646 | + configs = {'repcached': True} |
1647 | + |
1648 | + def f(c): |
1649 | + return configs.get(c, None) |
1650 | + |
1651 | + self.config.side_effect = f |
1652 | + |
1653 | + relations = { |
1654 | + 'replica': '10.0.0.2', |
1655 | + 'master': '10.0.0.1', |
1656 | + } |
1657 | + |
1658 | + def r(k): |
1659 | + return relations.get(k, None) |
1660 | + |
1661 | + self.relation_get.side_effect = r |
1662 | + self.unit_get.return_value = '10.0.0.3' |
1663 | + |
1664 | + self.peer_units.return_value = [0, 0] |
1665 | + self.oldest_peer.return_value = False |
1666 | + |
1667 | + self.assertRaises(Exception, memcached_hooks.cluster_relation_changed) |
1668 | + |
1669 | + @mock.patch('replication.get_current_replica') |
1670 | + @mock.patch('replication.unit_get') |
1671 | + @mock.patch('memcached_hooks.ufw.service') |
1672 | + @mock.patch('memcached_hooks.cache_relation_joined') |
1673 | + @mock.patch('charmhelpers.core.templating.render') |
1674 | + @mock.patch('replication.get_repcached_replica') |
1675 | + @mock.patch('memcached_utils.dpkg_info_contains') |
1676 | + def test_config_changed_replica(self, dpkg, get_replica, render, |
1677 | + cache_joined, ufw, unit_get, replica): |
1678 | + params = { |
1679 | + 'tcp_port': None, |
1680 | + 'disable_cas': None, |
1681 | + 'mem_size': None, |
1682 | + 'factor': None, |
1683 | + 'connection_limit': None, |
1684 | + 'udp_port': None, |
1685 | + 'slab_page_size': None, |
1686 | + 'threads': None, |
1687 | + 'large_pages_enabled': False, |
1688 | + 'min_item_size': None, |
1689 | + 'repcached_port': None, |
1690 | + 'request_limit': None, |
1691 | + 'disable_auto_cleanup': None, |
1692 | + } |
1693 | + |
1694 | + dpkg.return_value = True |
1695 | + replica.return_value = ('10.0.0.2') |
1696 | + |
1697 | + configs = { |
1698 | + 'repcached': True, |
1699 | + 'memsize': 1, |
1700 | + } |
1701 | + |
1702 | + def f(c): |
1703 | + return configs.get(c, None) |
1704 | + |
1705 | + self.config.side_effect = f |
1706 | + self.unit_get.return_value = '10.0.0.1' |
1707 | + |
1708 | + memcached_hooks.config_changed() |
1709 | + |
1710 | + params.update({'replica': '10.0.0.2'}) |
1711 | + ufw.assert_called_with(params.get('repcached_port'), 'open') |
1712 | + render.assert_called_with('memcached.conf', |
1713 | + memcached_hooks.ETC_MEMCACHED_CONF, |
1714 | + params) |
1715 | + |
1716 | + @mock.patch('memcached_hooks.cache_relation_joined') |
1717 | + @mock.patch('charmhelpers.contrib.network.ufw.service') |
1718 | + @mock.patch('memcached_hooks.replication.get_current_replica') |
1719 | + @mock.patch('charmhelpers.core.templating.render') |
1720 | + @mock.patch('memcached_utils.dpkg_info_contains') |
1721 | + def test_config_changed_no_replica(self, dpkg, render, |
1722 | + replica, ufw, cache): |
1723 | + params = { |
1724 | + 'tcp_port': None, |
1725 | + 'disable_cas': None, |
1726 | + 'mem_size': None, |
1727 | + 'factor': None, |
1728 | + 'connection_limit': None, |
1729 | + 'udp_port': None, |
1730 | + 'slab_page_size': None, |
1731 | + 'threads': None, |
1732 | + 'replica': "10.0.0.2", |
1733 | + 'repcached_port': None, |
1734 | + 'large_pages_enabled': False, |
1735 | + 'min_item_size': None, |
1736 | + 'request_limit': None, |
1737 | + 'disable_auto_cleanup': None, |
1738 | + } |
1739 | + |
1740 | + replica.return_value = "10.0.0.2" |
1741 | + dpkg.return_value = True |
1742 | + |
1743 | + configs = { |
1744 | + 'repcached': True, |
1745 | + 'memsize': 1, |
1746 | + } |
1747 | + |
1748 | + def f(c): |
1749 | + return configs.get(c, None) |
1750 | + |
1751 | + self.config.side_effect = f |
1752 | + |
1753 | + memcached_hooks.config_changed() |
1754 | + render.assert_called_with('memcached.conf', |
1755 | + memcached_hooks.ETC_MEMCACHED_CONF, |
1756 | + params) |
A few minor comments inline. Overall looks pretty good. It'd be nice if the replication worked beyond 2 units, but it is what it is.