Merge lp:~niedbalski/charms/trusty/memcached/replication into lp:charms/trusty/memcached

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
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.

Description of the change

This change adds server side replication to memcached charm by using the repcached patches (http://repcached.lab.klab.org) for fixing bug https://bugs.launchpad.net/charms/+source/memcached/+bug/1421660

- 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

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.

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)

Subscribers

People subscribed via source and target branches