Merge lp:~frankban/charms/trusty/redis/initial-charm into lp:~juju-gui/charms/trusty/redis/trunk

Proposed by Francesco Banconi on 2015-05-21
Status: Merged
Approved by: Francesco Banconi on 2015-05-25
Approved revision: 1
Merge reported by: Francesco Banconi
Merged at revision: not available
Proposed branch: lp:~frankban/charms/trusty/redis/initial-charm
Merge into: lp:~juju-gui/charms/trusty/redis/trunk
Diff against target: 2166 lines (+2007/-0)
25 files modified
.bzrignore (+6/-0)
.lbox (+1/-0)
.lbox.check (+3/-0)
Makefile (+100/-0)
README.md (+82/-0)
config.yaml (+27/-0)
copyright (+18/-0)
hooks/configfile.py (+82/-0)
hooks/generic-hook (+7/-0)
hooks/hookutils.py (+24/-0)
hooks/install (+31/-0)
hooks/relations.py (+42/-0)
hooks/services.py (+98/-0)
hooks/serviceutils.py (+109/-0)
hooks/setup.py (+19/-0)
icon.svg (+468/-0)
metadata.yaml (+21/-0)
test-requirements.pip (+14/-0)
tests/test_10_deploy.py (+313/-0)
tests/tests.yaml (+10/-0)
unit_tests/test_configfile.py (+90/-0)
unit_tests/test_hookutils.py (+58/-0)
unit_tests/test_relations.py (+54/-0)
unit_tests/test_services.py (+28/-0)
unit_tests/test_serviceutils.py (+302/-0)
To merge this branch: bzr merge lp:~frankban/charms/trusty/redis/initial-charm
Reviewer Review Type Date Requested Status
Martin Hilton 2015-05-21 Approve on 2015-05-22
Review via email: mp+259812@code.launchpad.net

Commit Message

Initial implementation of the redis charm.

Description of the Change

Initial implementation of the redis charm.

The charm uses the services framework to implement
the hooks. The readme describes how to deploy
the development version and how to run the tests.

My apologies for the big branch, but this is the
initial commit and most of the code is docs and
tests.

To post a comment you must log in.
Francesco Banconi (frankban) wrote :

TODO: open/close ports, more config options.

Martin Hilton (martin-hilton) wrote :

LGTM, with a couple of concerns I'd like you to be satisfied with.

review: Approve
Francesco Banconi (frankban) wrote :

Thanks for the review Martin!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file '.bzrignore'
2--- .bzrignore 1970-01-01 00:00:00 +0000
3+++ .bzrignore 2015-05-21 17:29:23 +0000
4@@ -0,0 +1,6 @@
5+.coverage
6+.emacs*
7+.venv
8+precise/
9+tags
10+trusty/
11
12=== added file '.lbox'
13--- .lbox 1970-01-01 00:00:00 +0000
14+++ .lbox 2015-05-21 17:29:23 +0000
15@@ -0,0 +1,1 @@
16+propose -cr -for lp:~juju-gui/charms/trusty/redis/trunk
17
18=== added file '.lbox.check'
19--- .lbox.check 1970-01-01 00:00:00 +0000
20+++ .lbox.check 2015-05-21 17:29:23 +0000
21@@ -0,0 +1,3 @@
22+#!/bin/sh
23+
24+make lint unittest
25
26=== added file 'Makefile'
27--- Makefile 1970-01-01 00:00:00 +0000
28+++ Makefile 2015-05-21 17:29:23 +0000
29@@ -0,0 +1,100 @@
30+# Copyright 2015 Canonical Ltd.
31+# Licensed under the GPLv3, see copyright file for details.
32+
33+export JUJU_TEST_CHARM=redis
34+export JUJU_REPOSITORY=
35+
36+# Override those values to run functional tests with a different environment
37+# or Juju series.
38+SERIES=trusty
39+JUJU_ENV=local
40+
41+# Define system Debian dependencies: run "make sysdeps" to install them.
42+# Please keep them in alphabetical order.
43+SYSDEPS = charm-tools juju-core juju-local python-dev python-pip \
44+ python-virtualenv rsync
45+
46+PYTHON = python
47+VENV = .venv
48+VENV_ACTIVATE = $(VENV)/bin/activate
49+NOSE = $(VENV)/bin/nosetests
50+PIP = $(VENV)/bin/pip
51+FTESTS=$(shell find -L tests -type f -executable | sort)
52+
53+.DEFAULT_GOAL := setup
54+
55+.PHONY: help
56+help:
57+ @echo -e 'Redis charm - list of make targets:\n'
58+ @echo 'make - Set up the development and testing environment.'
59+ @echo 'make test - Run tests (including unit and functional tests).'
60+ @echo 'make lint - Run linter and pep8.'
61+ @echo 'make check - Run all the tests and lint.'
62+ @echo 'make unittest - Run unit tests.'
63+ @echo 'make ftest - Run functional tests.'
64+ @echo 'make clean - Get rid of bytecode files and virtual envs.'
65+ @echo 'make deploy - Deploy the local copy of the charm.'
66+ @echo -e '\nWhen using "make deploy" it is possible to override the series'
67+ @echo 'with the SERIES environment variable and the service name with '
68+ @echo 'the JUJU_SERVICE_NAME environment variable, for instance:'
69+ @echo ' make deploy SERIES=precise JUJU_SERVICE_NAME=myredis'
70+
71+.PHONY: setup
72+setup: $(VENV_ACTIVATE)
73+
74+.PHONY: sysdeps
75+sysdeps:
76+ sudo apt-get install --yes $(SYSDEPS)
77+
78+$(VENV_ACTIVATE): test-requirements.pip
79+ virtualenv --distribute -p $(PYTHON) $(VENV)
80+ $(PIP) install -r test-requirements.pip || \
81+ (touch test-requirements.pip; exit 1)
82+ @touch $(VENV_ACTIVATE)
83+
84+.PHONY: clean
85+clean:
86+ -$(RM) -rfv $(VENV) .coverage
87+ find . -name '*.pyc' -delete
88+
89+.PHONY: check
90+check: lint test
91+ juju charm proof
92+
93+.PHONY: lint
94+lint: setup
95+ @$(VENV)/bin/flake8 --show-source --exclude=$(VENV) \
96+ --filename *.py,install,generic-hook \
97+ hooks/ tests/ unit_tests/
98+
99+.PHONY: deploy
100+deploy:
101+ @# The use of readlink below is required for OS X.
102+ @$(eval export JUJU_REPOSITORY:=$(shell mktemp -d `readlink -f /tmp`/temp.XXXX))
103+ @echo "JUJU_REPOSITORY is $(JUJU_REPOSITORY)"
104+ @# Setting up the Juju repository.
105+ @mkdir $(JUJU_REPOSITORY)/${SERIES}
106+ @rsync -a . $(JUJU_REPOSITORY)/${SERIES}/$(JUJU_TEST_CHARM) \
107+ --exclude .git --exclude .bzr --exclude tests --exclude unit_tests
108+ @# Deploying the charm.
109+ juju deploy local:${SERIES}/$(JUJU_TEST_CHARM) $(JUJU_SERVICE_NAME)
110+
111+.PHONY: test
112+test: unittest ftest
113+
114+.PHONY: ftest
115+ftest:
116+ juju bootstrap -e $(JUJU_ENV) --upload-tools
117+ @ # Wait for the environment to be ready.
118+ juju status -e $(JUJU_ENV)
119+ @# Setting the path is required because internally amulet calls
120+ @# juju-deployer using subprocess.
121+ PATH="$(VENV)/bin:$(PATH)" $(NOSE) --verbosity 2 -s $(FTESTS)
122+ juju destroy-environment $(JUJU_ENV) -y
123+ @# Clean up deployer garbage.
124+ @-$(RM) -rf trusty
125+
126+.PHONY: unittest
127+unittest: setup
128+ $(NOSE) --verbosity 2 -s -w unit_tests \
129+ --with-coverage --cover-package hooks --cover-erase
130
131=== added file 'README.md'
132--- README.md 1970-01-01 00:00:00 +0000
133+++ README.md 2015-05-21 17:29:23 +0000
134@@ -0,0 +1,82 @@
135+# Overview
136+
137+Redis (<http://redis.io>) is an open source, advanced key-value cache and
138+store. It is often referred to as a data structure server since keys can
139+contain strings, hashes, lists, sets, sorted sets, bitmaps and hyperloglogs.
140+In order to achieve its outstanding performance, Redis works with an in-memory
141+dataset that can be written to disk. Redis also supports master-slave
142+asynchronous replication.
143+
144+Redis can be configured in a master or slave configuration. This charm
145+provides a single stand alone implementation of Redis software supporting the
146+master-slave relationship. Go to the Redis web pages for more information on
147+[replication](http://redis.io/topics/replication).
148+
149+# Usage
150+
151+To deploy this charm first bootstrap your Juju environment and issue the
152+following command:
153+
154+ juju deploy redis
155+
156+Expose the master if you need to contact them for some reason.
157+
158+ juju expose redis
159+
160+# Replication
161+
162+Redis can be set up with master-slave replication in Juju. This allows the
163+Redis slave to be an exact copy of master server. A master can have multiple
164+slaves.
165+
166+To set up a master-slave scenario, deploy two services using this charm, one
167+for the master and one the slave server, then relate the two.
168+
169+ juju deploy redis redis1
170+ juju deploy redis redis2
171+ juju add-relation redis1:master redis2:slave
172+
173+# Connecting to the charm
174+
175+The charm provides a `db` relation for services wanting to connect to the Redis
176+service. When the relation established the following data is passed to the
177+related units:
178+
179+- `hostname`: the Redis server host name;
180+- `port`: the port the Redis server is listening to;
181+- `password`: the optional authentication password, or an empty string if no
182+ authentication is required.
183+
184+# Testing Redis
185+
186+To test if Redis software is functioning properly telnet to the redis ip
187+address using port 6379:
188+
189+ telnet <redis-ip> 6379
190+
191+You can also install the redis-tools package `apt-get install redis-tools`
192+and connect using the Redis client command:
193+
194+ redis-cli
195+
196+From there you can issue [Redis commands](http://redis.io/commands) to test
197+that Redis is working as intended.
198+
199+# Development and automated testing.
200+
201+To create a development environment, obtain a copy of the sources, run
202+`make sysdeps` to install the required system packages and then `make` to set
203+up the development virtual environment. At this point, it is possible to run
204+unit and functional tests, including lint checks, by executing `make check`.
205+
206+Run `make deploy` to deploy the local copy of the charm for development
207+purposes on your already bootstrapped environment.
208+
209+Use `make help` for further information about available make targets.
210+
211+# Redis Information
212+
213+- Redis [home page](http://redis.io/)
214+- Redis [github bug tracker](https://github.com/antirez/redis/issues)
215+- Redis [documentation](http://redis.io/documentation)
216+- Redis [mailing list](http://groups.google.com/group/redis-db)
217
218=== added file 'config.yaml'
219--- config.yaml 1970-01-01 00:00:00 +0000
220+++ config.yaml 2015-05-21 17:29:23 +0000
221@@ -0,0 +1,27 @@
222+options:
223+ port:
224+ type: int
225+ default: 6379
226+ description: |
227+ Accept connections on the specified port.
228+ password:
229+ type: string
230+ default: ""
231+ description: |
232+ Require clients to issue AUTH <PASSWORD> before processing any other
233+ commands.
234+ loglevel:
235+ type: string
236+ default: notice
237+ description: |
238+ Specify the Redis server verbosity level. Choices are:
239+ - debug (a lot of information, useful for development/testing);
240+ - verbose (many rarely useful info, but not a mess like the debug level);
241+ - notice (moderately verbose, what you want in production probably);
242+ - warning (only very important / critical messages are logged).
243+ logfile:
244+ type: string
245+ default: /var/log/redis/redis-server.log
246+ description: |
247+ Spcify the log file name.
248+
249
250=== added file 'copyright'
251--- copyright 1970-01-01 00:00:00 +0000
252+++ copyright 2015-05-21 17:29:23 +0000
253@@ -0,0 +1,18 @@
254+Format: http://dep.debian.net/deps/dep5/
255+
256+Files: *
257+Copyright: Copyright 2015, Canonical Ltd., All Rights Reserved.
258+License: GPL-3
259+ This program is free software: you can redistribute it and/or modify
260+ it under the terms of the GNU General Public License as published by
261+ the Free Software Foundation, either version 3 of the License, or
262+ (at your option) any later version.
263+ .
264+ This program is distributed in the hope that it will be useful,
265+ but WITHOUT ANY WARRANTY; without even the implied warranty of
266+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
267+ GNU General Public License for more details.
268+ .
269+ You should have received a copy of the GNU General Public License
270+ along with this program. If not, see <http://www.gnu.org/licenses/>.
271+
272
273=== added directory 'hooks'
274=== added symlink 'hooks/config-changed'
275=== target is u'generic-hook'
276=== added file 'hooks/configfile.py'
277--- hooks/configfile.py 1970-01-01 00:00:00 +0000
278+++ hooks/configfile.py 2015-05-21 17:29:23 +0000
279@@ -0,0 +1,82 @@
280+# Copyright 2015 Canonical Ltd.
281+# Licensed under the GPLv3, see copyright file for details.
282+
283+"""Utilities for working with the redis configuration file."""
284+
285+import errno
286+import os
287+import shutil
288+import tempfile
289+
290+
291+# Define the paths to the redis default and customized configuration file.
292+DEFAULT_REDIS_CONF = '/etc/redis/redis.conf'
293+REDIS_CONF = '/etc/redis/redis-charm.conf'
294+
295+
296+def include_config(target):
297+ """Include target configuration file at the end of the default config.
298+
299+ Note that this function is not idempotent. For this reason, it is only safe
300+ to call it in the install hook.
301+
302+ Raise an IOError if the configuration file does not exist or it is not
303+ writable.
304+ """
305+ _backup(DEFAULT_REDIS_CONF)
306+ with open(DEFAULT_REDIS_CONF, 'a') as conf_file:
307+ conf_file.write('include {}\n'.format(target))
308+
309+
310+def write(options, target):
311+ """Write the redis customized configuration file.
312+
313+ Receive the redis charm configuration options and the target file where to
314+ write configuration to.
315+
316+ Report whether the new and old configurations differ.
317+ Raise an IOError if a problem is encountered in the operation.
318+ """
319+ try:
320+ old_content = open(target, 'r').read()
321+ except IOError as err:
322+ if err.errno != errno.ENOENT:
323+ raise
324+ # It is acceptable that the customized configuration does not exist.
325+ old_content = ''
326+ new_content = ''.join(
327+ '{} {}\n'.format(key, value) for key, value in sorted(options.items()))
328+ # If there are no differences in the new and old configuration, we can
329+ # avoid writing the file and/or doing backups.
330+ if new_content == old_content:
331+ return False
332+ # The backup is only done if the target file has content.
333+ if old_content:
334+ _backup(target)
335+ # Write the new configuration in a new file, then rename to the real file.
336+ # Since the renaming operation may fail on some Unix flavors if the source
337+ # and destination files are on different file systems, use for the
338+ # temporary file the same directory where the target is stored.
339+ dirname = os.path.dirname(target)
340+ temp_file = tempfile.NamedTemporaryFile(
341+ prefix='charm-new-config-', dir=dirname, delete=False)
342+ temp_file.write(new_content)
343+ # Ensure that all the data is written to disk.
344+ temp_file.flush()
345+ os.fsync(temp_file.fileno())
346+ temp_file.close()
347+ os.chmod(temp_file.name, 0644)
348+ # Rename the temporary file to the real target file.
349+ os.rename(temp_file.name, target)
350+ return True
351+
352+
353+def _backup(filename):
354+ """Create a backup copy of the given file.
355+
356+ Return the path to the backup copy.
357+ Raise an IOError if a problem occurs while copying the file.
358+ """
359+ backup_filename = filename + '.bak'
360+ shutil.copyfile(filename, backup_filename)
361+ return backup_filename
362
363=== added symlink 'hooks/db-relation-broken'
364=== target is u'generic-hook'
365=== added symlink 'hooks/db-relation-changed'
366=== target is u'generic-hook'
367=== added symlink 'hooks/db-relation-departed'
368=== target is u'generic-hook'
369=== added symlink 'hooks/db-relation-joined'
370=== target is u'generic-hook'
371=== added file 'hooks/generic-hook'
372--- hooks/generic-hook 1970-01-01 00:00:00 +0000
373+++ hooks/generic-hook 2015-05-21 17:29:23 +0000
374@@ -0,0 +1,7 @@
375+#!/usr/bin/python
376+
377+# Copyright 2015 Canonical Ltd.
378+# Licensed under the GPLv3, see copyright file for details.
379+
380+import services
381+services.manage()
382
383=== added file 'hooks/hookutils.py'
384--- hooks/hookutils.py 1970-01-01 00:00:00 +0000
385+++ hooks/hookutils.py 2015-05-21 17:29:23 +0000
386@@ -0,0 +1,24 @@
387+# Copyright 2015 Canonical Ltd.
388+# Licensed under the GPLv3, see copyright file for details.
389+
390+"""Helper functions for handling hooks execution."""
391+
392+import functools
393+
394+from charmhelpers.core import hookenv
395+
396+
397+def hook_name_logged(function):
398+ """Decorate the given function so that the current hook name is logged.
399+
400+ The given function must accept no arguments.
401+ """
402+ @functools.wraps(function)
403+ def decorated():
404+ hook_name = hookenv.hook_name()
405+ hookenv.log('>>> Entering hook: {}.'.format(hook_name))
406+ try:
407+ return function()
408+ finally:
409+ hookenv.log('<<< Exiting hook: {}.'.format(hook_name))
410+ return decorated
411
412=== added file 'hooks/install'
413--- hooks/install 1970-01-01 00:00:00 +0000
414+++ hooks/install 2015-05-21 17:29:23 +0000
415@@ -0,0 +1,31 @@
416+#!/usr/bin/python
417+
418+# Copyright 2015 Canonical Ltd.
419+# Licensed under the GPLv3, see copyright file for details.
420+
421+import setup
422+setup.pre_install()
423+
424+from charmhelpers import fetch
425+from charmhelpers.core import hookenv
426+
427+import configfile
428+import hookutils
429+
430+
431+# Define Debian packages to be installed.
432+PACKAGES = ['redis-server']
433+
434+
435+@hookutils.hook_name_logged
436+def install():
437+ """Install the Debian packages required by redis."""
438+ hookenv.log('Installing system packages.')
439+ fetch.apt_install(fetch.filter_installed_packages(PACKAGES))
440+ # Include the customized configuration file at the end of the default
441+ # redis configuration file.
442+ configfile.include_config(configfile.REDIS_CONF)
443+
444+
445+if __name__ == "__main__":
446+ install()
447
448=== added symlink 'hooks/master-relation-broken'
449=== target is u'generic-hook'
450=== added symlink 'hooks/master-relation-changed'
451=== target is u'generic-hook'
452=== added symlink 'hooks/master-relation-departed'
453=== target is u'generic-hook'
454=== added symlink 'hooks/master-relation-joined'
455=== target is u'generic-hook'
456=== added file 'hooks/relations.py'
457--- hooks/relations.py 1970-01-01 00:00:00 +0000
458+++ hooks/relations.py 2015-05-21 17:29:23 +0000
459@@ -0,0 +1,42 @@
460+# Copyright 2015 Canonical Ltd.
461+# Licensed under the GPLv3, see copyright file for details.
462+
463+"""Define service relations for the redis charm."""
464+
465+from charmhelpers.core import hookenv
466+from charmhelpers.core.services import helpers
467+
468+
469+class DbRelation(helpers.RelationContext):
470+ """Define the redis db relation.
471+
472+ Subscribers are provided the server "hostname", "port" and "password"
473+ values in the relation payload. If the redis server does not use
474+ authentication, the password is an empty string.
475+ """
476+
477+ name = 'db'
478+ interface = 'redis'
479+
480+ def provide_data(self):
481+ """Return data to be relation_set for this interface."""
482+ config = hookenv.config()
483+ return {
484+ 'hostname': hookenv.unit_get('public-address'),
485+ 'port': config['port'],
486+ 'password': config['password'].strip(),
487+ }
488+
489+
490+class MasterRelation(DbRelation):
491+ """Define the redis master relation."""
492+
493+ name = 'master'
494+
495+
496+class SlaveRelation(helpers.RelationContext):
497+ """Define the redis slave relation."""
498+
499+ name = 'slave'
500+ interface = 'redis'
501+ required_keys = ['hostname', 'port']
502
503=== added file 'hooks/services.py'
504--- hooks/services.py 1970-01-01 00:00:00 +0000
505+++ hooks/services.py 2015-05-21 17:29:23 +0000
506@@ -0,0 +1,98 @@
507+#!/usr/bin/python
508+
509+# Copyright 2015 Canonical Ltd.
510+# Licensed under the GPLv3, see copyright file for details.
511+
512+"""Redis charm service definitions and management.
513+
514+This charm uses the service framework to handle all of its hooks except for the
515+install hook. See https://pythonhosted.org/charmhelpers/examples/services.html
516+
517+Two service definitions are provided to the manager: redis-master and
518+redis-slave. The idea is that either the former or the latter can be ready at
519+the same time, but not both or none. If the "redis1:master redis2:slave"
520+relation is established and ready, then the redis-slave service definition is
521+enabled on redis2 units. In all the other cases the redis-master definition is
522+enabled.
523+
524+The redis server itself is always running, and it is only restarted when a
525+change is detected in its configuration file, due to charm config changes or to
526+slave relation established.
527+"""
528+
529+from charmhelpers.core import hookenv
530+from charmhelpers.core.services import base
531+
532+import hookutils
533+import serviceutils
534+import relations
535+
536+
537+@hookutils.hook_name_logged
538+def manage():
539+ """Set up the service manager for redis."""
540+ config = hookenv.config()
541+ # Handle relations.
542+ db_relation = relations.DbRelation()
543+ master_relation = relations.MasterRelation()
544+ slave_relation = relations.SlaveRelation()
545+ slave_relation_ready = slave_relation.is_ready()
546+
547+ # Set up the service manager.
548+ manager = base.ServiceManager([
549+ {
550+ # The name of the redis master service.
551+ 'service': 'redis-master',
552+
553+ # Ports to open when the service starts.
554+ 'ports': [config['port']],
555+
556+ # Context managers for provided relations.
557+ 'provided_data': [db_relation, master_relation],
558+
559+ # Data (contexts) required to start the service.
560+ 'required_data': [config, not slave_relation_ready],
561+
562+ # Callables called when required data is ready.
563+ 'data_ready': [
564+ serviceutils.write_config_file(
565+ config,
566+ db_relation=db_relation,
567+ master_relation=master_relation),
568+ ],
569+
570+ # Callables called when it is time to start the service.
571+ 'start': [serviceutils.service_start],
572+
573+ # Callables called when it is time to stop the service.
574+ 'stop': [serviceutils.service_stop],
575+ },
576+ {
577+ # The name of the redis slave service.
578+ 'service': 'redis-slave',
579+
580+ # Ports to open when the service starts.
581+ 'ports': [config['port']],
582+
583+ # Context managers for provided relations.
584+ 'provided_data': [db_relation],
585+
586+ # Data (contexts) required to start the service.
587+ 'required_data': [config, slave_relation_ready],
588+
589+ # Callables called when required data is ready.
590+ 'data_ready': [
591+ serviceutils.write_config_file(
592+ config,
593+ db_relation=db_relation,
594+ slave_relation=slave_relation),
595+ ],
596+
597+ # Callables called when it is time to start the service.
598+ 'start': [serviceutils.service_start],
599+
600+ # Callables called when it is time to stop the service.
601+ 'stop': [serviceutils.service_stop],
602+ }
603+ ])
604+ manager.manage()
605
606=== added file 'hooks/serviceutils.py'
607--- hooks/serviceutils.py 1970-01-01 00:00:00 +0000
608+++ hooks/serviceutils.py 2015-05-21 17:29:23 +0000
609@@ -0,0 +1,109 @@
610+# Copyright 2015 Canonical Ltd.
611+# Licensed under the GPLv3, see copyright file for details.
612+
613+"""Service manager helpers.
614+
615+This module includes closures and callbacks suitable to be used when
616+registering callables in the services framework manager.
617+"""
618+
619+from charmhelpers.core import (
620+ hookenv,
621+ host,
622+)
623+
624+import configfile
625+
626+
627+# Define the name of the init service set up when installing redis.
628+SERVICE_NAME = 'redis-server'
629+
630+
631+def service_start(service_name):
632+ """Start the service if not already running."""
633+ if not host.service_running(SERVICE_NAME):
634+ hookenv.log('Starting service {}.'.format(service_name))
635+ host.service_start(SERVICE_NAME)
636+
637+
638+def service_stop(service_name):
639+ """Stop the service if it is running and if the stop hook is executing."""
640+ if hookenv.hook_name() == 'stop' and host.service_running(SERVICE_NAME):
641+ # There is no need to stop the service if we are not in the stop hook.
642+ hookenv.log('Stopping service {}.'.format(service_name))
643+ host.service_stop(SERVICE_NAME)
644+ # XXX (frankban): remove redis package and clean up files.
645+
646+
647+def write_config_file(
648+ config, db_relation=None, master_relation=None, slave_relation=None):
649+ """Wrap the configfile.write function building options for the config.
650+
651+ The config argument is the hook environment configuration.
652+ The relation arguments are relation context, and when passed they are
653+ assumed to be ready.
654+
655+ Return a function that can be used as a callback in the services framework,
656+ and that generates the redis configuration file.
657+
658+ This returned functions also takes care of restarting the service if the
659+ configuration changed.
660+ """
661+ def callback(service_name):
662+ options = _get_service_options(config, slave_relation)
663+ hookenv.log('Writing configuration file for {}.'.format(service_name))
664+ changed = configfile.write(options, configfile.REDIS_CONF)
665+ if changed:
666+ hookenv.log('Restarting service due to configuration change.')
667+ host.service_restart(SERVICE_NAME)
668+ # If the configuration changed, it is possible that related units
669+ # require notification of changes. For this reason, update all the
670+ # existing established relations. This is required because
671+ # "services.provide_data" is only called when the current hook
672+ # is a relation joined or changed.
673+ _update_relations(filter(None, [db_relation, master_relation]))
674+ else:
675+ hookenv.log('No changes detected in the configuration file.')
676+
677+ return callback
678+
679+
680+def _get_service_options(config, slave_relation=None):
681+ """Return a dict containing the redis service configuration options.
682+
683+ Receive the hook environment config object and optionally the slave
684+ relation context.
685+ """
686+ hookenv.log('Retrieving service options.')
687+ # To introduce more redis configuration options in the charm, add them to
688+ # the config.yaml file and to the dictionary returned by this function.
689+ # If the new options are relevant while establishing relations, also update
690+ # the "provide_data" methods in the relation contexts defined in
691+ # relations.py.
692+ options = {
693+ 'bind': hookenv.unit_get('public-address'),
694+ 'logfile': config['logfile'],
695+ 'loglevel': config['loglevel'],
696+ 'port': config['port'],
697+ }
698+ password = config['password'].strip()
699+ if password:
700+ options['requirepass'] = password
701+ if slave_relation is not None:
702+ hookenv.log('Setting up slave relation.')
703+ # If slave_relation is defined, it is assumed that the relation is
704+ # ready, i.e. that the slave_relation dict evaluates to True.
705+ data = slave_relation[slave_relation.name][0]
706+ options['slaveof'] = '{hostname} {port}'.format(**data)
707+ password = data.get('password')
708+ if password:
709+ options['masterauth'] = password
710+ return options
711+
712+
713+def _update_relations(relations):
714+ """Update existing established relations."""
715+ for relation in relations:
716+ for relation_id in hookenv.relation_ids(relation.name):
717+ hookenv.log('Updating data for relation {}.'.format(relation.name))
718+ hookenv.relation_set(relation_id, relation.provide_data())
719
720=== added file 'hooks/setup.py'
721--- hooks/setup.py 1970-01-01 00:00:00 +0000
722+++ hooks/setup.py 2015-05-21 17:29:23 +0000
723@@ -0,0 +1,19 @@
724+# Copyright 2015 Canonical Ltd.
725+# Licensed under the GPLv3, see copyright file for details.
726+
727+"""Set up charmhelpers on the unit."""
728+
729+
730+def pre_install():
731+ """Do any setup required before the install hook."""
732+ install_charmhelpers()
733+
734+
735+def install_charmhelpers():
736+ """Install the charmhelpers library, if not present."""
737+ try:
738+ import charmhelpers # noqa
739+ except ImportError:
740+ import subprocess
741+ subprocess.check_call(['apt-get', 'install', '-y', 'python-pip'])
742+ subprocess.check_call(['pip', 'install', 'charmhelpers==0.3.1'])
743
744=== added symlink 'hooks/slave-relation-broken'
745=== target is u'generic-hook'
746=== added symlink 'hooks/slave-relation-changed'
747=== target is u'generic-hook'
748=== added symlink 'hooks/slave-relation-departed'
749=== target is u'generic-hook'
750=== added symlink 'hooks/slave-relation-joined'
751=== target is u'generic-hook'
752=== added symlink 'hooks/start'
753=== target is u'generic-hook'
754=== added symlink 'hooks/stop'
755=== target is u'generic-hook'
756=== added symlink 'hooks/upgrade-charm'
757=== target is u'generic-hook'
758=== added file 'icon.svg'
759--- icon.svg 1970-01-01 00:00:00 +0000
760+++ icon.svg 2015-05-21 17:29:23 +0000
761@@ -0,0 +1,468 @@
762+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
763+<!-- Created with Inkscape (http://www.inkscape.org/) -->
764+
765+<svg
766+ xmlns:dc="http://purl.org/dc/elements/1.1/"
767+ xmlns:cc="http://creativecommons.org/ns#"
768+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
769+ xmlns:svg="http://www.w3.org/2000/svg"
770+ xmlns="http://www.w3.org/2000/svg"
771+ xmlns:xlink="http://www.w3.org/1999/xlink"
772+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
773+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
774+ width="96"
775+ height="96"
776+ id="svg6517"
777+ version="1.1"
778+ inkscape:version="0.48+devel r12505"
779+ sodipodi:docname="redis.svg">
780+ <defs
781+ id="defs6519">
782+ <filter
783+ style="color-interpolation-filters:sRGB;"
784+ inkscape:label="Inner Shadow"
785+ id="filter1121">
786+ <feFlood
787+ flood-opacity="0.59999999999999998"
788+ flood-color="rgb(0,0,0)"
789+ result="flood"
790+ id="feFlood1123" />
791+ <feComposite
792+ in="flood"
793+ in2="SourceGraphic"
794+ operator="out"
795+ result="composite1"
796+ id="feComposite1125" />
797+ <feGaussianBlur
798+ in="composite1"
799+ stdDeviation="1"
800+ result="blur"
801+ id="feGaussianBlur1127" />
802+ <feOffset
803+ dx="0"
804+ dy="2"
805+ result="offset"
806+ id="feOffset1129" />
807+ <feComposite
808+ in="offset"
809+ in2="SourceGraphic"
810+ operator="atop"
811+ result="composite2"
812+ id="feComposite1131" />
813+ </filter>
814+ <filter
815+ style="color-interpolation-filters:sRGB;"
816+ inkscape:label="Drop Shadow"
817+ id="filter950">
818+ <feFlood
819+ flood-opacity="0.25"
820+ flood-color="rgb(0,0,0)"
821+ result="flood"
822+ id="feFlood952" />
823+ <feComposite
824+ in="flood"
825+ in2="SourceGraphic"
826+ operator="in"
827+ result="composite1"
828+ id="feComposite954" />
829+ <feGaussianBlur
830+ in="composite1"
831+ stdDeviation="4"
832+ result="blur"
833+ id="feGaussianBlur956" />
834+ <feOffset
835+ dx="0"
836+ dy="1"
837+ result="offset"
838+ id="feOffset958" />
839+ <feComposite
840+ in="SourceGraphic"
841+ in2="offset"
842+ operator="over"
843+ result="composite2"
844+ id="feComposite960" />
845+ </filter>
846+ <linearGradient
847+ id="Background">
848+ <stop
849+ id="stop4178"
850+ offset="0"
851+ style="stop-color:#22779e;stop-opacity:1" />
852+ <stop
853+ id="stop4180"
854+ offset="1"
855+ style="stop-color:#2991c0;stop-opacity:1" />
856+ </linearGradient>
857+ <clipPath
858+ clipPathUnits="userSpaceOnUse"
859+ id="clipPath873">
860+ <g
861+ transform="matrix(0,-0.66666667,0.66604479,0,-258.25992,677.00001)"
862+ id="g875"
863+ inkscape:label="Layer 1"
864+ style="fill:#ff00ff;fill-opacity:1;stroke:none;display:inline">
865+ <path
866+ style="fill:#ff00ff;fill-opacity:1;stroke:none;display:inline"
867+ d="m 46.702703,898.22775 50.594594,0 C 138.16216,898.22775 144,904.06497 144,944.92583 l 0,50.73846 c 0,40.86071 -5.83784,46.69791 -46.702703,46.69791 l -50.594594,0 C 5.8378378,1042.3622 0,1036.525 0,995.66429 L 0,944.92583 C 0,904.06497 5.8378378,898.22775 46.702703,898.22775 Z"
868+ id="path877"
869+ inkscape:connector-curvature="0"
870+ sodipodi:nodetypes="sssssssss" />
871+ </g>
872+ </clipPath>
873+ <filter
874+ inkscape:collect="always"
875+ id="filter891"
876+ inkscape:label="Badge Shadow">
877+ <feGaussianBlur
878+ inkscape:collect="always"
879+ stdDeviation="0.71999962"
880+ id="feGaussianBlur893" />
881+ </filter>
882+ <style
883+ id="style867"
884+ type="text/css"><![CDATA[
885+ .fil0 {fill:#1F1A17}
886+ ]]></style>
887+ <linearGradient
888+ inkscape:collect="always"
889+ xlink:href="#linearGradient4439"
890+ id="linearGradient908"
891+ x1="-220"
892+ y1="731.29077"
893+ x2="-220"
894+ y2="635.29077"
895+ gradientUnits="userSpaceOnUse" />
896+ <clipPath
897+ id="clipPath16">
898+ <path
899+ id="path18"
900+ d="m -9,-9 614,0 0,231 -614,0 0,-231 z" />
901+ </clipPath>
902+ <clipPath
903+ id="clipPath116">
904+ <path
905+ id="path118"
906+ d="m 91.7368,146.3253 -9.7039,-1.577 -8.8548,-3.8814 -7.5206,-4.7308 -7.1566,-8.7335 -4.0431,-4.282 -3.9093,-1.4409 -1.034,2.5271 1.8079,2.6096 0.4062,3.6802 1.211,-0.0488 1.3232,-1.2069 -0.3569,3.7488 -1.4667,0.9839 0.0445,1.4286 -3.4744,-1.9655 -3.1462,-3.712 -0.6559,-3.3176 1.3453,-2.6567 1.2549,-4.5133 2.5521,-1.2084 2.6847,0.1318 2.5455,1.4791 -1.698,-8.6122 1.698,-9.5825 -1.8692,-4.4246 -6.1223,-6.5965 1.0885,-3.941 2.9002,-4.5669 5.4688,-3.8486 2.9007,-0.3969 3.225,-0.1094 -2.012,-8.2601 7.3993,-3.0326 9.2188,-1.2129 3.1535,2.0619 0.2427,5.5797 3.5178,5.8224 0.2426,4.6094 8.4909,-0.6066 7.8843,0.7279 -7.8843,-4.7307 1.3343,-5.701 4.9731,-7.763 4.8521,-2.0622 3.8814,1.5769 1.577,3.1538 8.1269,6.1861 1.5769,-1.3343 12.7363,-0.485 2.5473,2.0619 0.2426,3.6391 -0.849,1.5767 -0.6066,9.8251 -4.2454,8.4909 0.7276,3.7605 2.5475,-1.3343 7.1566,-6.6716 3.5175,-0.2424 3.8815,1.5769 3.8818,2.9109 1.9406,6.3077 11.4021,-0.7277 6.914,2.6686 5.5797,5.2157 4.0028,7.5206 0.9706,8.8546 -0.8493,10.3105 -2.1832,9.2185 -2.1836,2.9112 -3.0322,0.9706 -5.3373,-5.8224 -4.8518,-1.6982 -4.2455,7.0353 -4.2454,3.8815 -2.3049,1.4556 -9.2185,7.6419 -7.3993,4.0028 -7.3993,0.6066 -8.6119,-1.4556 -7.5206,-2.7899 -5.2158,-4.2454 -4.1241,-4.9734 -4.2454,-1.2129" />
907+ </clipPath>
908+ <clipPath
909+ id="clipPath128">
910+ <path
911+ id="path130"
912+ d="m 91.7368,146.3253 -9.7039,-1.577 -8.8548,-3.8814 -7.5206,-4.7308 -7.1566,-8.7335 -4.0431,-4.282 -3.9093,-1.4409 -1.034,2.5271 1.8079,2.6096 0.4062,3.6802 1.211,-0.0488 1.3232,-1.2069 -0.3569,3.7488 -1.4667,0.9839 0.0445,1.4286 -3.4744,-1.9655 -3.1462,-3.712 -0.6559,-3.3176 1.3453,-2.6567 1.2549,-4.5133 2.5521,-1.2084 2.6847,0.1318 2.5455,1.4791 -1.698,-8.6122 1.698,-9.5825 -1.8692,-4.4246 -6.1223,-6.5965 1.0885,-3.941 2.9002,-4.5669 5.4688,-3.8486 2.9007,-0.3969 3.225,-0.1094 -2.012,-8.2601 7.3993,-3.0326 9.2188,-1.2129 3.1535,2.0619 0.2427,5.5797 3.5178,5.8224 0.2426,4.6094 8.4909,-0.6066 7.8843,0.7279 -7.8843,-4.7307 1.3343,-5.701 4.9731,-7.763 4.8521,-2.0622 3.8814,1.5769 1.577,3.1538 8.1269,6.1861 1.5769,-1.3343 12.7363,-0.485 2.5473,2.0619 0.2426,3.6391 -0.849,1.5767 -0.6066,9.8251 -4.2454,8.4909 0.7276,3.7605 2.5475,-1.3343 7.1566,-6.6716 3.5175,-0.2424 3.8815,1.5769 3.8818,2.9109 1.9406,6.3077 11.4021,-0.7277 6.914,2.6686 5.5797,5.2157 4.0028,7.5206 0.9706,8.8546 -0.8493,10.3105 -2.1832,9.2185 -2.1836,2.9112 -3.0322,0.9706 -5.3373,-5.8224 -4.8518,-1.6982 -4.2455,7.0353 -4.2454,3.8815 -2.3049,1.4556 -9.2185,7.6419 -7.3993,4.0028 -7.3993,0.6066 -8.6119,-1.4556 -7.5206,-2.7899 -5.2158,-4.2454 -4.1241,-4.9734 -4.2454,-1.2129" />
913+ </clipPath>
914+ <linearGradient
915+ id="linearGradient3850"
916+ inkscape:collect="always">
917+ <stop
918+ id="stop3852"
919+ offset="0"
920+ style="stop-color:#000000;stop-opacity:1;" />
921+ <stop
922+ id="stop3854"
923+ offset="1"
924+ style="stop-color:#000000;stop-opacity:0;" />
925+ </linearGradient>
926+ <clipPath
927+ clipPathUnits="userSpaceOnUse"
928+ id="clipPath3095">
929+ <path
930+ d="m 976.648,389.551 -842.402,0 0,839.999 842.402,0 0,-839.999"
931+ id="path3097"
932+ inkscape:connector-curvature="0" />
933+ </clipPath>
934+ <linearGradient
935+ id="linearGradient4439"
936+ inkscape:collect="always">
937+ <stop
938+ id="stop4441"
939+ offset="0"
940+ style="stop-color:#e6e6e6;stop-opacity:1" />
941+ <stop
942+ id="stop4443"
943+ offset="1"
944+ style="stop-color:#f2f2f2;stop-opacity:1" />
945+ </linearGradient>
946+ <clipPath
947+ clipPathUnits="userSpaceOnUse"
948+ id="clipPath3195">
949+ <path
950+ d="m 611.836,756.738 -106.34,105.207 c -8.473,8.289 -13.617,20.102 -13.598,33.379 L 598.301,790.207 c -0.031,-13.418 5.094,-25.031 13.535,-33.469"
951+ id="path3197"
952+ inkscape:connector-curvature="0" />
953+ </clipPath>
954+ <clipPath
955+ clipPathUnits="userSpaceOnUse"
956+ id="clipPath3235">
957+ <path
958+ d="m 1095.64,1501.81 c 35.46,-35.07 70.89,-70.11 106.35,-105.17 4.4,-4.38 7.11,-10.53 7.11,-17.55 l -106.37,105.21 c 0,7 -2.71,13.11 -7.09,17.51"
959+ id="path3237"
960+ inkscape:connector-curvature="0" />
961+ </clipPath>
962+ <clipPath
963+ id="clipPath4591"
964+ clipPathUnits="userSpaceOnUse">
965+ <path
966+ inkscape:connector-curvature="0"
967+ d="m 1106.6009,730.43734 -0.036,21.648 c -0.01,3.50825 -2.8675,6.61375 -6.4037,6.92525 l -83.6503,7.33162 c -3.5205,0.30763 -6.3812,-2.29987 -6.3671,-5.8145 l 0.036,-21.6475 20.1171,-1.76662 -0.011,4.63775 c 0,1.83937 1.4844,3.19925 3.3262,3.0395 l 49.5274,-4.33975 c 1.8425,-0.166 3.3425,-1.78125 3.3538,-3.626 l 0.01,-4.63025 20.1,-1.7575"
968+ style="fill:#ff00ff;fill-opacity:1;fill-rule:nonzero;stroke:none"
969+ id="path4593" />
970+ </clipPath>
971+ <radialGradient
972+ gradientUnits="userSpaceOnUse"
973+ gradientTransform="matrix(-1.4333926,-2.2742838,1.1731823,-0.73941125,-174.08025,98.374394)"
974+ r="20.40658"
975+ fy="93.399292"
976+ fx="-26.508606"
977+ cy="93.399292"
978+ cx="-26.508606"
979+ id="radialGradient3856"
980+ xlink:href="#linearGradient3850"
981+ inkscape:collect="always" />
982+ <linearGradient
983+ gradientTransform="translate(-318.48033,212.32022)"
984+ gradientUnits="userSpaceOnUse"
985+ y2="993.19702"
986+ x2="-51.879555"
987+ y1="593.11615"
988+ x1="348.20132"
989+ id="linearGradient3895"
990+ xlink:href="#linearGradient3850"
991+ inkscape:collect="always" />
992+ <clipPath
993+ id="clipPath3906"
994+ clipPathUnits="userSpaceOnUse">
995+ <rect
996+ transform="scale(1,-1)"
997+ style="opacity:0.8;color:#000000;fill:#ff00ff;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
998+ id="rect3908"
999+ width="1019.1371"
1000+ height="1019.1371"
1001+ x="357.9816"
1002+ y="-1725.8152" />
1003+ </clipPath>
1004+ </defs>
1005+ <sodipodi:namedview
1006+ id="base"
1007+ pagecolor="#ffffff"
1008+ bordercolor="#666666"
1009+ borderopacity="1.0"
1010+ inkscape:pageopacity="0.0"
1011+ inkscape:pageshadow="2"
1012+ inkscape:zoom="4.0745361"
1013+ inkscape:cx="52.997123"
1014+ inkscape:cy="44.931814"
1015+ inkscape:document-units="px"
1016+ inkscape:current-layer="layer3"
1017+ showgrid="false"
1018+ fit-margin-top="0"
1019+ fit-margin-left="0"
1020+ fit-margin-right="0"
1021+ fit-margin-bottom="0"
1022+ inkscape:window-width="1920"
1023+ inkscape:window-height="1029"
1024+ inkscape:window-x="0"
1025+ inkscape:window-y="24"
1026+ inkscape:window-maximized="1"
1027+ showborder="true"
1028+ showguides="true"
1029+ inkscape:guide-bbox="true"
1030+ inkscape:showpageshadow="false"
1031+ inkscape:snap-global="true"
1032+ inkscape:snap-bbox="true"
1033+ inkscape:bbox-paths="true"
1034+ inkscape:bbox-nodes="true"
1035+ inkscape:snap-bbox-edge-midpoints="true"
1036+ inkscape:snap-bbox-midpoints="true"
1037+ inkscape:object-paths="true"
1038+ inkscape:snap-intersection-paths="true"
1039+ inkscape:object-nodes="true"
1040+ inkscape:snap-smooth-nodes="true"
1041+ inkscape:snap-midpoints="true"
1042+ inkscape:snap-object-midpoints="true"
1043+ inkscape:snap-center="true">
1044+ <inkscape:grid
1045+ type="xygrid"
1046+ id="grid821" />
1047+ <sodipodi:guide
1048+ orientation="1,0"
1049+ position="16,48"
1050+ id="guide823" />
1051+ <sodipodi:guide
1052+ orientation="0,1"
1053+ position="64,80"
1054+ id="guide825" />
1055+ <sodipodi:guide
1056+ orientation="1,0"
1057+ position="80,40"
1058+ id="guide827" />
1059+ <sodipodi:guide
1060+ orientation="0,1"
1061+ position="64,16"
1062+ id="guide829" />
1063+ </sodipodi:namedview>
1064+ <metadata
1065+ id="metadata6522">
1066+ <rdf:RDF>
1067+ <cc:Work
1068+ rdf:about="">
1069+ <dc:format>image/svg+xml</dc:format>
1070+ <dc:type
1071+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
1072+ <dc:title></dc:title>
1073+ </cc:Work>
1074+ </rdf:RDF>
1075+ </metadata>
1076+ <g
1077+ inkscape:label="BACKGROUND"
1078+ inkscape:groupmode="layer"
1079+ id="layer1"
1080+ transform="translate(268,-635.29076)"
1081+ style="display:inline">
1082+ <path
1083+ style="fill:url(#linearGradient908);fill-opacity:1.0;stroke:none;display:inline;filter:url(#filter1121)"
1084+ d="m -268,700.15563 0,-33.72973 c 0,-27.24324 3.88785,-31.13513 31.10302,-31.13513 l 33.79408,0 c 27.21507,0 31.1029,3.89189 31.1029,31.13513 l 0,33.72973 c 0,27.24325 -3.88783,31.13514 -31.1029,31.13514 l -33.79408,0 C -264.11215,731.29077 -268,727.39888 -268,700.15563 Z"
1085+ id="path6455"
1086+ inkscape:connector-curvature="0"
1087+ sodipodi:nodetypes="sssssssss" />
1088+ </g>
1089+ <g
1090+ inkscape:groupmode="layer"
1091+ id="layer3"
1092+ inkscape:label="PLACE YOUR PICTOGRAM HERE"
1093+ style="display:inline">
1094+ <g
1095+ id="g4115"
1096+ transform="matrix(0.37621289,0,0,0.37621289,-48.453493,-64.869307)"
1097+ style="filter:url(#filter950)">
1098+ <path
1099+ id="path4127"
1100+ d="m 334.77,339.54 c -9.078,4.732 -56.106,24.068 -66.118,29.287 -10.012,5.221 -15.574,5.17 -23.483,1.389 -7.909,-3.781 -57.955,-23.996 -66.97,-28.305 -4.506,-2.154 -6.875,-3.971 -6.875,-5.688 l 0,-17.195 c 0,0 65.153,-14.184 75.672,-17.957 10.518,-3.774 14.167,-3.91 23.118,-0.631 8.952,3.279 62.474,12.936 71.321,16.176 0,0 -0.004,15.357 -0.004,16.951 0.001,1.7 -2.04,3.565 -6.661,5.973 z"
1101+ inkscape:connector-curvature="0"
1102+ style="fill:#a41e11" />
1103+ <path
1104+ id="path4129"
1105+ d="m 334.77,322.34 c -9.078,4.73 -56.106,24.068 -66.118,29.287 -10.012,5.221 -15.574,5.17 -23.483,1.389 -7.91,-3.779 -57.955,-23.998 -66.97,-28.305 -9.015,-4.309 -9.204,-7.275 -0.348,-10.742 8.855,-3.469 58.626,-22.996 69.146,-26.77 10.518,-3.772 14.167,-3.91 23.118,-0.63 8.952,3.279 55.699,21.886 64.545,25.126 8.848,3.243 9.188,5.913 0.11,10.645 z"
1106+ inkscape:connector-curvature="0"
1107+ style="fill:#d82c20" />
1108+ <path
1109+ id="path4131"
1110+ d="m 334.77,311.5 c -9.078,4.732 -56.106,24.068 -66.118,29.289 -10.012,5.219 -15.574,5.168 -23.483,1.387 -7.91,-3.779 -57.955,-23.996 -66.97,-28.305 -4.506,-2.154 -6.875,-3.969 -6.875,-5.686 l 0,-17.197 c 0,0 65.153,-14.183 75.672,-17.957 10.518,-3.773 14.167,-3.91 23.118,-0.631 8.952,3.279 62.474,12.934 71.321,16.175 0,0 -0.004,15.357 -0.004,16.953 0.001,1.699 -2.04,3.564 -6.661,5.972 z"
1111+ inkscape:connector-curvature="0"
1112+ style="fill:#a41e11" />
1113+ <path
1114+ id="path4133"
1115+ d="m 334.77,294.3 c -9.078,4.732 -56.106,24.068 -66.118,29.289 -10.012,5.219 -15.574,5.168 -23.483,1.387 -7.91,-3.779 -57.955,-23.997 -66.97,-28.305 -9.015,-4.308 -9.204,-7.274 -0.348,-10.743 8.855,-3.467 58.626,-22.995 69.146,-26.768 10.518,-3.773 14.167,-3.91 23.118,-0.631 8.952,3.279 55.699,21.885 64.545,25.126 8.848,3.242 9.188,5.913 0.11,10.645 z"
1116+ inkscape:connector-curvature="0"
1117+ style="fill:#d82c20" />
1118+ <path
1119+ id="path4135"
1120+ d="m 334.77,282.42 c -9.078,4.732 -56.106,24.069 -66.118,29.29 -10.012,5.219 -15.574,5.168 -23.483,1.387 -7.91,-3.779 -57.955,-23.997 -66.97,-28.305 -4.506,-2.154 -6.875,-3.97 -6.875,-5.686 l 0,-17.197 c 0,0 65.153,-14.183 75.672,-17.956 10.518,-3.774 14.167,-3.91 23.118,-0.631 8.952,3.279 62.474,12.934 71.321,16.175 0,0 -0.004,15.357 -0.004,16.952 0.001,1.698 -2.04,3.563 -6.661,5.971 z"
1121+ inkscape:connector-curvature="0"
1122+ style="fill:#a41e11" />
1123+ <path
1124+ id="path4137"
1125+ d="m 334.77,265.22 c -9.078,4.732 -56.106,24.069 -66.118,29.289 -10.012,5.219 -15.574,5.168 -23.483,1.388 -7.909,-3.78 -57.955,-23.997 -66.97,-28.305 -9.015,-4.308 -9.204,-7.275 -0.348,-10.743 8.855,-3.468 58.626,-22.994 69.146,-26.768 10.518,-3.774 14.167,-3.91 23.118,-0.63 8.952,3.279 55.699,21.885 64.545,25.126 8.848,3.24 9.188,5.912 0.11,10.643 z"
1126+ inkscape:connector-curvature="0"
1127+ style="fill:#d82c20" />
1128+ <polygon
1129+ id="polygon4139"
1130+ points="247.17,236.13 259.06,240.78 270.27,237.11 267.24,244.38 278.67,248.66 263.93,250.19 260.63,258.13 255.3,249.27 238.28,247.74 250.98,243.16 "
1131+ style="fill:#ffffff" />
1132+ <polygon
1133+ id="polygon4141"
1134+ points="259.75,287.18 232.24,275.77 271.66,269.72 "
1135+ style="fill:#ffffff" />
1136+ <ellipse
1137+ id="ellipse4143"
1138+ cy="261.23999"
1139+ ry="8.1669998"
1140+ rx="21.069"
1141+ cx="221.61"
1142+ d="m 242.679,261.23999 c 0,4.51051 -9.43291,8.167 -21.069,8.167 -11.63609,0 -21.069,-3.65649 -21.069,-8.167 0,-4.51051 9.43291,-8.167 21.069,-8.167 11.63609,0 21.069,3.65649 21.069,8.167 z"
1143+ sodipodi:cx="221.61"
1144+ sodipodi:cy="261.23999"
1145+ sodipodi:rx="21.069"
1146+ sodipodi:ry="8.1669998"
1147+ style="fill:#ffffff" />
1148+ <polygon
1149+ id="polygon4145"
1150+ points="296.09,250.83 319.42,260.05 296.11,269.26 "
1151+ style="fill:#7a0c00" />
1152+ <polygon
1153+ id="polygon4147"
1154+ points="296.11,269.26 293.58,270.25 270.28,261.04 296.09,250.83 "
1155+ style="fill:#ad2115" />
1156+ </g>
1157+ </g>
1158+ <g
1159+ inkscape:groupmode="layer"
1160+ id="layer2"
1161+ inkscape:label="BADGE"
1162+ style="display:none"
1163+ sodipodi:insensitive="true">
1164+ <g
1165+ style="display:inline"
1166+ transform="translate(-340.00001,-581)"
1167+ id="g4394"
1168+ clip-path="none">
1169+ <g
1170+ id="g855">
1171+ <g
1172+ inkscape:groupmode="maskhelper"
1173+ id="g870"
1174+ clip-path="url(#clipPath873)"
1175+ style="opacity:0.6;filter:url(#filter891)">
1176+ <path
1177+ transform="matrix(1.4999992,0,0,1.4999992,-29.999795,-237.54282)"
1178+ d="m 264,552.36218 c 0,6.62742 -5.37258,12 -12,12 -6.62742,0 -12,-5.37258 -12,-12 0,-6.62741 5.37258,-12 12,-12 6.62742,0 12,5.37259 12,12 z"
1179+ sodipodi:ry="12"
1180+ sodipodi:rx="12"
1181+ sodipodi:cy="552.36218"
1182+ sodipodi:cx="252"
1183+ id="path844"
1184+ style="color:#000000;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
1185+ sodipodi:type="arc" />
1186+ </g>
1187+ <g
1188+ id="g862">
1189+ <path
1190+ sodipodi:type="arc"
1191+ style="color:#000000;fill:#f5f5f5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
1192+ id="path4398"
1193+ sodipodi:cx="252"
1194+ sodipodi:cy="552.36218"
1195+ sodipodi:rx="12"
1196+ sodipodi:ry="12"
1197+ d="m 264,552.36218 c 0,6.62742 -5.37258,12 -12,12 -6.62742,0 -12,-5.37258 -12,-12 0,-6.62741 5.37258,-12 12,-12 6.62742,0 12,5.37259 12,12 z"
1198+ transform="matrix(1.4999992,0,0,1.4999992,-29.999795,-238.54282)" />
1199+ <path
1200+ transform="matrix(1.25,0,0,1.25,33,-100.45273)"
1201+ d="m 264,552.36218 c 0,6.62742 -5.37258,12 -12,12 -6.62742,0 -12,-5.37258 -12,-12 0,-6.62741 5.37258,-12 12,-12 6.62742,0 12,5.37259 12,12 z"
1202+ sodipodi:ry="12"
1203+ sodipodi:rx="12"
1204+ sodipodi:cy="552.36218"
1205+ sodipodi:cx="252"
1206+ id="path4400"
1207+ style="color:#000000;fill:#dd4814;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
1208+ sodipodi:type="arc" />
1209+ <path
1210+ sodipodi:type="star"
1211+ style="color:#000000;fill:#f5f5f5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
1212+ id="path4459"
1213+ sodipodi:sides="5"
1214+ sodipodi:cx="666.19574"
1215+ sodipodi:cy="589.50385"
1216+ sodipodi:r1="7.2431178"
1217+ sodipodi:r2="4.3458705"
1218+ sodipodi:arg1="1.0471976"
1219+ sodipodi:arg2="1.6755161"
1220+ inkscape:flatsided="false"
1221+ inkscape:rounded="0.1"
1222+ inkscape:randomized="0"
1223+ d="m 669.8173,595.77657 c -0.39132,0.22593 -3.62645,-1.90343 -4.07583,-1.95066 -0.44938,-0.0472 -4.05653,1.36297 -4.39232,1.06062 -0.3358,-0.30235 0.68963,-4.03715 0.59569,-4.47913 -0.0939,-0.44198 -2.5498,-3.43681 -2.36602,-3.8496 0.18379,-0.41279 4.05267,-0.59166 4.44398,-0.81759 0.39132,-0.22593 2.48067,-3.48704 2.93005,-3.4398 0.44938,0.0472 1.81505,3.67147 2.15084,3.97382 0.3358,0.30236 4.08294,1.2817 4.17689,1.72369 0.0939,0.44198 -2.9309,2.86076 -3.11469,3.27355 -0.18379,0.41279 0.0427,4.27917 -0.34859,4.5051 z"
1224+ transform="matrix(1.511423,-0.16366377,0.16366377,1.511423,-755.37346,-191.93651)" />
1225+ </g>
1226+ </g>
1227+ </g>
1228+ </g>
1229+</svg>
1230
1231=== added file 'metadata.yaml'
1232--- metadata.yaml 1970-01-01 00:00:00 +0000
1233+++ metadata.yaml 2015-05-21 17:29:23 +0000
1234@@ -0,0 +1,21 @@
1235+name: redis
1236+maintainer: Juju UI Team <juju-gui@lists.ubuntu.com>
1237+summary: Persistent key-value database with network interface
1238+description: |
1239+ Redis is a key-value database in a similar vein to memcache but the
1240+ dataset is non-volatile. Redis additionally provides native support
1241+ for atomically manipulating and querying data structures such as lists
1242+ and sets. The dataset is stored entirely in memory and periodically
1243+ flushed to disk.
1244+ This charm supports data replication and provides both the master and the
1245+ slave services.
1246+tags:
1247+ - databases
1248+provides:
1249+ master:
1250+ interface: redis
1251+ db:
1252+ interface: redis
1253+requires:
1254+ slave:
1255+ interface: redis
1256
1257=== added file 'test-requirements.pip'
1258--- test-requirements.pip 1970-01-01 00:00:00 +0000
1259+++ test-requirements.pip 2015-05-21 17:29:23 +0000
1260@@ -0,0 +1,14 @@
1261+# Copyright 2015 Canonical Ltd.
1262+# Licensed under the GPLv3, see copyright file for details.
1263+
1264+amulet==1.10.1
1265+bzr==2.6.0 # Required by juju-deployer.
1266+charmhelpers==0.3.1
1267+coverage==3.7.1
1268+flake8==2.4.1
1269+juju-deployer==0.4.3 # Required by amulet.
1270+mock==1.0.1
1271+nose==1.3.6
1272+PyYAML==3.11 # Required by charmhelpers.
1273+six==1.9.0 # Required by charmhelpers.
1274+
1275
1276=== added directory 'tests'
1277=== added file 'tests/test_10_deploy.py'
1278--- tests/test_10_deploy.py 1970-01-01 00:00:00 +0000
1279+++ tests/test_10_deploy.py 2015-05-21 17:29:23 +0000
1280@@ -0,0 +1,313 @@
1281+#!/usr/bin/env python3
1282+
1283+# Copyright 2015 Canonical Ltd.
1284+# Licensed under the GPLv3, see copyright file for details.
1285+
1286+"""Redis charm functional tests.
1287+
1288+These tests use the Amulet test helpers:
1289+see https://jujucharms.com/docs/stable/tools-amulet
1290+"""
1291+
1292+import itertools
1293+from pkg_resources import resource_filename
1294+import sys
1295+import telnetlib
1296+import unittest
1297+
1298+import amulet
1299+
1300+# Allow importing modules and packages from the hooks directory.
1301+sys.path.append(resource_filename(__name__, '../hooks'))
1302+
1303+import configfile
1304+
1305+
1306+# Define the charm name.
1307+CHARM_NAME = 'redis'
1308+
1309+
1310+class RedisClient(object):
1311+ """A very simple and naive telnet redis client used for tests."""
1312+
1313+ def __init__(self, host, port=6379):
1314+ """Initialize the client."""
1315+ self._host = host
1316+ self._port = port
1317+ self._client = None
1318+
1319+ def connect(self, password=None):
1320+ """Connect to the client."""
1321+ self._client = telnetlib.Telnet(self._host, self._port)
1322+ if password is not None:
1323+ self._client.write('AUTH {}\n'.format(password))
1324+ response = self._readline()
1325+ if response != '+OK':
1326+ raise ValueError('authentication error: {}'.format(response))
1327+
1328+ def close(self):
1329+ """Close the client connection."""
1330+ if self._client is not None:
1331+ self._client.close()
1332+ self._client = None
1333+
1334+ def set(self, key, value):
1335+ """Set a key in the redis database, with the given value."""
1336+ self._client.write('SET {} {}\n'.format(key, value))
1337+ response = self._readline()
1338+ if response != '+OK':
1339+ raise ValueError('unexpected response: {}'.format(response))
1340+
1341+ def get(self, key):
1342+ """Return the value corresponding to key from the redis database.
1343+
1344+ Return None if the key is not found.
1345+ """
1346+ self._client.write('GET {}\n'.format(key))
1347+ response = self._readline()
1348+ if response == '$-1':
1349+ return None
1350+ return self._readline()
1351+
1352+ def _readline(self):
1353+ """Read next line from the client connection."""
1354+ return self._client.read_until('\r\n').strip()
1355+
1356+
1357+_counter = itertools.count()
1358+
1359+
1360+def get_service_name():
1361+ """Return an incremental redis service name."""
1362+ return 'redis{}'.format(next(_counter))
1363+
1364+
1365+def deploy(options=None):
1366+ """Deploy one unit of the given service using the redis charm.
1367+
1368+ Return the Amulet deployment and the unit object.
1369+ """
1370+ deployment = amulet.Deployment(series='trusty')
1371+ service_name = get_service_name()
1372+ deployment.add(service_name, charm=CHARM_NAME)
1373+ if options is not None:
1374+ deployment.configure(service_name, options)
1375+ deployment.expose(service_name)
1376+ try:
1377+ deployment.setup(timeout=900)
1378+ deployment.sentry.wait()
1379+ except amulet.helpers.TimeoutError:
1380+ amulet.raise_status(
1381+ amulet.FAIL, msg='Environment was not stood up in time.')
1382+ return deployment, deployment.sentry.unit[service_name + '/0']
1383+
1384+
1385+def deploy_master_slave(master_options=None, slave_options=None):
1386+ """Deploy two redis services related in a master-slave relationship.
1387+
1388+ Return the Amulet deployment and the two unit objects.
1389+ """
1390+ deployment = amulet.Deployment(series='trusty')
1391+ master, slave = get_service_name(), get_service_name()
1392+ deployment.add(master, charm=CHARM_NAME)
1393+ deployment.add(slave, charm=CHARM_NAME)
1394+ if master_options is not None:
1395+ deployment.configure(master, master_options)
1396+ if slave_options is not None:
1397+ deployment.configure(slave, slave_options)
1398+ deployment.relate(master + ':master', slave + ':slave')
1399+ deployment.expose(master)
1400+ deployment.expose(slave)
1401+ try:
1402+ deployment.setup(timeout=900)
1403+ deployment.sentry.wait()
1404+ except amulet.helpers.TimeoutError:
1405+ amulet.raise_status(
1406+ amulet.FAIL, msg='Environment was not stood up in time.')
1407+ units = deployment.sentry.unit
1408+ return deployment, units[master + '/0'], units[slave + '/0']
1409+
1410+
1411+class TestDeployment(unittest.TestCase):
1412+
1413+ @classmethod
1414+ def setUpClass(cls):
1415+ # Set up the environment and deploy the charm.
1416+ cls.deployment, cls.unit = deploy()
1417+
1418+ @classmethod
1419+ def tearDownClass(cls):
1420+ # Remove the redis service.
1421+ cls.deployment.remove_service(cls.unit.info['service'])
1422+
1423+ def test_config_file(self):
1424+ expected_content = (
1425+ 'bind {}\n'
1426+ 'logfile /var/log/redis/redis-server.log\n'
1427+ 'loglevel notice\n'
1428+ 'port 6379\n'
1429+ ).format(self.unit.info['public-address'])
1430+ self.assertEqual(
1431+ expected_content,
1432+ self.unit.file_contents(configfile.REDIS_CONF))
1433+
1434+ def test_connection(self):
1435+ client = RedisClient(self.unit.info['public-address'])
1436+ client.connect()
1437+ self.addCleanup(client.close)
1438+ self.assertIsNone(client.get('my-key'))
1439+ client.set('my-key', 'my-value')
1440+ self.assertEqual('my-value', client.get('my-key'))
1441+
1442+
1443+class TestDeploymentOptions(unittest.TestCase):
1444+
1445+ options = {
1446+ 'port': 4242,
1447+ 'password': 'secret',
1448+ 'loglevel': 'verbose',
1449+ 'logfile': '/tmp/redis.log',
1450+ }
1451+
1452+ @classmethod
1453+ def setUpClass(cls):
1454+ # Set up the environment and deploy the charm.
1455+ cls.deployment, cls.unit = deploy(options=cls.options)
1456+
1457+ @classmethod
1458+ def tearDownClass(cls):
1459+ # Remove the redis service.
1460+ cls.deployment.remove_service(cls.unit.info['service'])
1461+
1462+ def test_config_file(self):
1463+ expected_content = (
1464+ 'bind {}\n'
1465+ 'logfile /tmp/redis.log\n'
1466+ 'loglevel verbose\n'
1467+ 'port 4242\n'
1468+ 'requirepass secret\n'
1469+ ).format(self.unit.info['public-address'])
1470+ self.assertEqual(
1471+ expected_content,
1472+ self.unit.file_contents(configfile.REDIS_CONF))
1473+
1474+ def test_connection(self):
1475+ client = RedisClient(
1476+ self.unit.info['public-address'], port=self.options['port'])
1477+ client.connect(password=self.options['password'])
1478+ self.addCleanup(client.close)
1479+ self.assertIsNone(client.get('my-key'))
1480+ client.set('my-key', 'my-value')
1481+ self.assertEqual('my-value', client.get('my-key'))
1482+
1483+
1484+class TestMasterSlaveRelation(unittest.TestCase):
1485+
1486+ @classmethod
1487+ def setUpClass(cls):
1488+ # Set up the environment and deploy the charm.
1489+ cls.deployment, cls.master, cls.slave = deploy_master_slave()
1490+
1491+ @classmethod
1492+ def tearDownClass(cls):
1493+ # Remove the redis master and slave services.
1494+ cls.deployment.remove_service(cls.slave.info['service'])
1495+ cls.deployment.remove_service(cls.master.info['service'])
1496+
1497+ def test_master_config_file(self):
1498+ expected_content = (
1499+ 'bind {}\n'
1500+ 'logfile /var/log/redis/redis-server.log\n'
1501+ 'loglevel notice\n'
1502+ 'port 6379\n'
1503+ ).format(self.master.info['public-address'])
1504+ self.assertEqual(
1505+ expected_content,
1506+ self.master.file_contents(configfile.REDIS_CONF))
1507+
1508+ def test_slave_config_file(self):
1509+ expected_content = (
1510+ 'bind {}\n'
1511+ 'logfile /var/log/redis/redis-server.log\n'
1512+ 'loglevel notice\n'
1513+ 'port 6379\n'
1514+ 'slaveof {} 6379\n'
1515+ ).format(
1516+ self.slave.info['public-address'],
1517+ self.master.info['public-address'])
1518+ self.assertEqual(
1519+ expected_content,
1520+ self.slave.file_contents(configfile.REDIS_CONF))
1521+
1522+ def test_connection(self):
1523+ master_client = RedisClient(self.master.info['public-address'])
1524+ master_client.connect()
1525+ self.addCleanup(master_client.close)
1526+ master_client.set('my-key', '42')
1527+ # Retrieve the value from the slave.
1528+ slave_client = RedisClient(self.slave.info['public-address'])
1529+ slave_client.connect()
1530+ self.addCleanup(slave_client.close)
1531+ self.assertEqual('42', slave_client.get('my-key'))
1532+
1533+
1534+class TestMasterSlaveRelationOptions(unittest.TestCase):
1535+
1536+ master_options = {'password': 'secret'}
1537+ slave_options = {'port': 4747, 'loglevel': 'warning'}
1538+
1539+ @classmethod
1540+ def setUpClass(cls):
1541+ # Set up the environment and deploy the charm.
1542+ cls.deployment, cls.master, cls.slave = deploy_master_slave(
1543+ master_options=cls.master_options,
1544+ slave_options=cls.slave_options)
1545+
1546+ @classmethod
1547+ def tearDownClass(cls):
1548+ # Remove the redis master and slave services.
1549+ cls.deployment.remove_service(cls.slave.info['service'])
1550+ cls.deployment.remove_service(cls.master.info['service'])
1551+
1552+ def test_master_config_file(self):
1553+ expected_content = (
1554+ 'bind {}\n'
1555+ 'logfile /var/log/redis/redis-server.log\n'
1556+ 'loglevel notice\n'
1557+ 'port 6379\n'
1558+ 'requirepass secret\n'
1559+ ).format(self.master.info['public-address'])
1560+ self.assertEqual(
1561+ expected_content,
1562+ self.master.file_contents(configfile.REDIS_CONF))
1563+
1564+ def test_slave_config_file(self):
1565+ expected_content = (
1566+ 'bind {}\n'
1567+ 'logfile /var/log/redis/redis-server.log\n'
1568+ 'loglevel warning\n'
1569+ 'masterauth secret\n'
1570+ 'port 4747\n'
1571+ 'slaveof {} 6379\n'
1572+ ).format(
1573+ self.slave.info['public-address'],
1574+ self.master.info['public-address'])
1575+ self.assertEqual(
1576+ expected_content,
1577+ self.slave.file_contents(configfile.REDIS_CONF))
1578+
1579+ def test_connection(self):
1580+ master_client = RedisClient(self.master.info['public-address'])
1581+ master_client.connect(password=self.master_options['password'])
1582+ self.addCleanup(master_client.close)
1583+ master_client.set('my-key', '42')
1584+ # Retrieve the value from the slave.
1585+ slave_client = RedisClient(
1586+ self.slave.info['public-address'], port=self.slave_options['port'])
1587+ slave_client.connect()
1588+ self.addCleanup(slave_client.close)
1589+ self.assertEqual('42', slave_client.get('my-key'))
1590+
1591+
1592+if __name__ == '__main__':
1593+ unittest.main(verbosity=2)
1594
1595=== added file 'tests/tests.yaml'
1596--- tests/tests.yaml 1970-01-01 00:00:00 +0000
1597+++ tests/tests.yaml 2015-05-21 17:29:23 +0000
1598@@ -0,0 +1,10 @@
1599+bootstrap: true
1600+reset: false
1601+tests: "none"
1602+packages:
1603+ - build-essential
1604+ - charm-tools
1605+ - python-dev
1606+ - python-pip
1607+ - python-virtualenv
1608+ - rsync
1609
1610=== added directory 'unit_tests'
1611=== added file 'unit_tests/test_configfile.py'
1612--- unit_tests/test_configfile.py 1970-01-01 00:00:00 +0000
1613+++ unit_tests/test_configfile.py 2015-05-21 17:29:23 +0000
1614@@ -0,0 +1,90 @@
1615+# Copyright 2015 Canonical Ltd.
1616+# Licensed under the GPLv3, see copyright file for details.
1617+
1618+import os
1619+from pkg_resources import resource_filename
1620+import shutil
1621+import sys
1622+import tempfile
1623+import unittest
1624+
1625+import mock
1626+
1627+# Allow importing modules and packages from the hooks directory.
1628+sys.path.append(resource_filename(__name__, '../hooks'))
1629+
1630+import configfile
1631+
1632+
1633+class TestIncludeConfig(unittest.TestCase):
1634+
1635+ def test_success(self):
1636+ conf = tempfile.NamedTemporaryFile(delete=False)
1637+ self.addCleanup(os.remove, conf.name)
1638+ # Also remove the backup file created in the process.
1639+ self.addCleanup(os.remove, conf.name + '.bak')
1640+ conf.write('content\n')
1641+ conf.close()
1642+ with mock.patch('configfile.DEFAULT_REDIS_CONF', conf.name):
1643+ configfile.include_config('/my/customized/config')
1644+ expected_content = 'content\ninclude /my/customized/config\n'
1645+ self.assertEqual(expected_content, open(conf.name, 'r').read())
1646+
1647+ def test_not_found(self):
1648+ with mock.patch('configfile.DEFAULT_REDIS_CONF', '/no/such/file'):
1649+ with self.assertRaises(IOError) as ctx:
1650+ configfile.include_config('/my/customized/config')
1651+ expected_error = "[Errno 2] No such file or directory: '/no/such/file'"
1652+ self.assertEqual(expected_error, bytes(ctx.exception))
1653+
1654+
1655+class TestWrite(unittest.TestCase):
1656+
1657+ def make_target(self, content=None):
1658+ """Return a target file path in a temporary directory.
1659+
1660+ If content is not None, also create the target file itself with the
1661+ given content.
1662+ """
1663+ playground = tempfile.mkdtemp()
1664+ self.addCleanup(shutil.rmtree, playground)
1665+ target = os.path.join(playground, 'target')
1666+ if content is not None:
1667+ with open(target, 'w') as target_file:
1668+ target_file.write(content)
1669+ return target
1670+
1671+ def assert_file_content(self, target, content):
1672+ """Ensure a file exists with the given content."""
1673+ self.assertTrue(os.path.isfile(target))
1674+ self.assertEqual(content, open(target, 'r').read())
1675+
1676+ def test_unexisting_target(self):
1677+ target = self.make_target()
1678+ changed = configfile.write({'bind': '1.2.3.4', 'port': 4242}, target)
1679+ self.assertTrue(changed)
1680+ self.assert_file_content(target, 'bind 1.2.3.4\nport 4242\n')
1681+ self.assertFalse(os.path.exists(target + '.bak'))
1682+
1683+ def test_existing_target(self):
1684+ target = self.make_target('original content')
1685+ changed = configfile.write({'bind': '1.2.3.4', 'port': 7000}, target)
1686+ self.assertTrue(changed)
1687+ self.assert_file_content(target, 'bind 1.2.3.4\nport 7000\n')
1688+ self.assert_file_content(target + '.bak', 'original content')
1689+
1690+ def test_no_changes(self):
1691+ target = self.make_target('bind 1.2.3.4\n')
1692+ changed = configfile.write({'bind': '1.2.3.4'}, target)
1693+ self.assertFalse(changed)
1694+ self.assert_file_content(target, 'bind 1.2.3.4\n')
1695+ self.assertFalse(os.path.exists(target + '.bak'))
1696+
1697+ def test_error(self):
1698+ target = self.make_target('original content')
1699+ os.chmod(target, 0)
1700+ self.addCleanup(os.chmod, target, 0666)
1701+ with self.assertRaises(IOError) as ctx:
1702+ configfile.write({'bind': '1.2.3.4'}, target)
1703+ expected_error = "[Errno 13] Permission denied: '{}'".format(target)
1704+ self.assertEqual(expected_error, bytes(ctx.exception))
1705
1706=== added file 'unit_tests/test_hookutils.py'
1707--- unit_tests/test_hookutils.py 1970-01-01 00:00:00 +0000
1708+++ unit_tests/test_hookutils.py 2015-05-21 17:29:23 +0000
1709@@ -0,0 +1,58 @@
1710+# Copyright 2015 Canonical Ltd.
1711+# Licensed under the GPLv3, see copyright file for details.
1712+
1713+from pkg_resources import resource_filename
1714+import sys
1715+import unittest
1716+
1717+from charmhelpers.core import hookenv
1718+import mock
1719+
1720+# Allow importing modules and packages from the hooks directory.
1721+sys.path.append(resource_filename(__name__, '../hooks'))
1722+
1723+import hookutils
1724+
1725+
1726+def _successful_hook():
1727+ """An example successful hook used for tests."""
1728+ hookenv.log('executing')
1729+ return 42
1730+
1731+
1732+def _failing_hook():
1733+ """An example failing hook used for tests."""
1734+ hookenv.log('failing')
1735+ raise TypeError
1736+
1737+
1738+@mock.patch('charmhelpers.core.hookenv.hook_name')
1739+@mock.patch('charmhelpers.core.hookenv.log')
1740+class TestHookNameLogged(unittest.TestCase):
1741+
1742+ def test_successful_hook(self, mock_log, mock_hook_name):
1743+ mock_hook_name.return_value = 'config-changed'
1744+ decorated = hookutils.hook_name_logged(_successful_hook)
1745+ result = decorated()
1746+ self.assertEqual(42, result)
1747+ mock_hook_name.assert_called_once_with()
1748+ self.assertEqual(3, mock_log.call_count)
1749+ mock_log.assert_has_calls([
1750+ mock.call('>>> Entering hook: config-changed.'),
1751+ mock.call('executing'),
1752+ mock.call('<<< Exiting hook: config-changed.'),
1753+ ])
1754+
1755+ def test_failing_hook(self, mock_log, mock_hook_name):
1756+ mock_hook_name.return_value = 'start'
1757+ decorated = hookutils.hook_name_logged(_failing_hook)
1758+ with self.assertRaises(TypeError):
1759+ decorated()
1760+ mock_hook_name.assert_called_once_with()
1761+ # Even if the hook raised an exception, exiting it is still logged.
1762+ self.assertEqual(3, mock_log.call_count)
1763+ mock_log.assert_has_calls([
1764+ mock.call('>>> Entering hook: start.'),
1765+ mock.call('failing'),
1766+ mock.call('<<< Exiting hook: start.')
1767+ ])
1768
1769=== added file 'unit_tests/test_relations.py'
1770--- unit_tests/test_relations.py 1970-01-01 00:00:00 +0000
1771+++ unit_tests/test_relations.py 2015-05-21 17:29:23 +0000
1772@@ -0,0 +1,54 @@
1773+# Copyright 2015 Canonical Ltd.
1774+# Licensed under the GPLv3, see copyright file for details.
1775+
1776+from pkg_resources import resource_filename
1777+import sys
1778+import unittest
1779+
1780+import mock
1781+
1782+# Allow importing modules and packages from the hooks directory.
1783+sys.path.append(resource_filename(__name__, '../hooks'))
1784+
1785+import relations
1786+
1787+
1788+def patch_config(data):
1789+ """Patch the "charmhelpers.core.hookenv.config" function.
1790+
1791+ The mocked function returns the given value.
1792+ """
1793+ return mock.patch(
1794+ 'charmhelpers.core.hookenv.config',
1795+ lambda: data)
1796+
1797+
1798+def patch_unit_get(value):
1799+ """Patch the "charmhelpers.core.hookenv.unit_get" function.
1800+
1801+ The mocked function returns the given value.
1802+ """
1803+ return mock.patch(
1804+ 'charmhelpers.core.hookenv.unit_get',
1805+ mock.Mock(return_value=value))
1806+
1807+
1808+@mock.patch('charmhelpers.core.hookenv.log', mock.Mock())
1809+class TestDbRelation(unittest.TestCase):
1810+
1811+ def setUp(self):
1812+ relation_ids_path = 'charmhelpers.core.hookenv.relation_ids'
1813+ with mock.patch(relation_ids_path, mock.MagicMock()):
1814+ self.relation = relations.DbRelation()
1815+
1816+ def test_provide_data(self):
1817+ with patch_config({'port': 4242, 'password': 'secret!'}):
1818+ with patch_unit_get('1.2.3.4') as mock_unit_get:
1819+ data = self.relation.provide_data()
1820+ expected_data = {
1821+ 'hostname': '1.2.3.4',
1822+ 'port': 4242,
1823+ 'password': 'secret!',
1824+ }
1825+ self.assertEqual(expected_data, data)
1826+ mock_unit_get.assert_called_once_with('public-address')
1827
1828=== added file 'unit_tests/test_services.py'
1829--- unit_tests/test_services.py 1970-01-01 00:00:00 +0000
1830+++ unit_tests/test_services.py 2015-05-21 17:29:23 +0000
1831@@ -0,0 +1,28 @@
1832+# Copyright 2015 Canonical Ltd.
1833+# Licensed under the GPLv3, see copyright file for details.
1834+
1835+from pkg_resources import resource_filename
1836+import sys
1837+import unittest
1838+
1839+import mock
1840+
1841+# Allow importing modules and packages from the hooks directory.
1842+sys.path.append(resource_filename(__name__, '../hooks'))
1843+
1844+import services
1845+
1846+
1847+@mock.patch('charmhelpers.core.hookenv.config')
1848+@mock.patch('charmhelpers.core.hookenv.log', mock.Mock())
1849+@mock.patch('charmhelpers.core.hookenv.relation_ids', mock.MagicMock())
1850+@mock.patch('charmhelpers.core.services.base.ServiceManager')
1851+class TestManage(unittest.TestCase):
1852+
1853+ def test_services(self, mock_manager, mock_config):
1854+ services.manage()
1855+ self.assertEqual(1, mock_manager.call_count)
1856+ definitions = mock_manager.call_args[0][0]
1857+ service_names = [i['service'] for i in definitions]
1858+ self.assertEqual(['redis-master', 'redis-slave'], service_names)
1859+ mock_config.assert_called_once_with()
1860
1861=== added file 'unit_tests/test_serviceutils.py'
1862--- unit_tests/test_serviceutils.py 1970-01-01 00:00:00 +0000
1863+++ unit_tests/test_serviceutils.py 2015-05-21 17:29:23 +0000
1864@@ -0,0 +1,302 @@
1865+# Copyright 2015 Canonical Ltd.
1866+# Licensed under the GPLv3, see copyright file for details.
1867+
1868+import contextlib
1869+from pkg_resources import resource_filename
1870+import sys
1871+import unittest
1872+
1873+import mock
1874+
1875+# Allow importing modules and packages from the hooks directory.
1876+sys.path.append(resource_filename(__name__, '../hooks'))
1877+
1878+import configfile
1879+import serviceutils
1880+
1881+
1882+def patch_service_running(value):
1883+ """Patch the "charmhelpers.core.host.service_running" function.
1884+
1885+ The mocked function returns the given value.
1886+ """
1887+ return mock.patch(
1888+ 'charmhelpers.core.host.service_running',
1889+ lambda service_name: value)
1890+
1891+
1892+def patch_hook_name(value):
1893+ """Patch the "charmhelpers.core.hookenv.hook_name" function.
1894+
1895+ The mocked function returns the given value.
1896+ """
1897+ return mock.patch('charmhelpers.core.hookenv.hook_name', lambda: value)
1898+
1899+
1900+@mock.patch('charmhelpers.core.host.service_start')
1901+@mock.patch('charmhelpers.core.hookenv.log')
1902+class TestServiceStart(unittest.TestCase):
1903+
1904+ def test_not_running(self, mock_log, mock_service_start):
1905+ with patch_service_running(False):
1906+ serviceutils.service_start('foo')
1907+ mock_service_start.assert_called_once_with(serviceutils.SERVICE_NAME)
1908+ mock_log.assert_called_once_with('Starting service foo.')
1909+
1910+ def test_already_running(self, mock_log, mock_service_start):
1911+ with patch_service_running(True):
1912+ serviceutils.service_start('foo')
1913+ self.assertFalse(mock_service_start.called)
1914+ self.assertFalse(mock_log.called)
1915+
1916+
1917+@mock.patch('charmhelpers.core.host.service_stop')
1918+@mock.patch('charmhelpers.core.hookenv.log')
1919+class TestServiceStop(unittest.TestCase):
1920+
1921+ def test_service_running_stop_hook(self, mock_log, mock_service_stop):
1922+ with patch_service_running(True):
1923+ with patch_hook_name('stop'):
1924+ serviceutils.service_stop('foo')
1925+ mock_service_stop.assert_called_once_with(serviceutils.SERVICE_NAME)
1926+ mock_log.assert_called_once_with('Stopping service foo.')
1927+
1928+ def test_service_not_running_stop_hook(self, mock_log, mock_service_stop):
1929+ with patch_service_running(False):
1930+ with patch_hook_name('stop'):
1931+ serviceutils.service_stop('foo')
1932+ self.assertFalse(mock_service_stop.called)
1933+ self.assertFalse(mock_log.called)
1934+
1935+ def test_service_running_other_hook(self, mock_log, mock_service_stop):
1936+ with patch_service_running(True):
1937+ with patch_hook_name('config-changed'):
1938+ serviceutils.service_stop('foo')
1939+ self.assertFalse(mock_service_stop.called)
1940+ self.assertFalse(mock_log.called)
1941+
1942+ def test_service_not_running_other_hook(self, mock_log, mock_service_stop):
1943+ with patch_service_running(False):
1944+ with patch_hook_name('config-changed'):
1945+ serviceutils.service_stop('foo')
1946+ self.assertFalse(mock_service_stop.called)
1947+ self.assertFalse(mock_log.called)
1948+
1949+
1950+def make_relation(data):
1951+ """Create and return a mock relation with the given data."""
1952+ relation = type('Relation', (dict,), {
1953+ 'name': 'testing',
1954+ 'provide_data': lambda self: data,
1955+ })()
1956+ relation['testing'] = [data]
1957+ return relation
1958+
1959+
1960+class TestWriteConfigFile(unittest.TestCase):
1961+
1962+ @contextlib.contextmanager
1963+ def patch_all(self, configuration_changed=False):
1964+ """Mock all the external functions used by write_config_file."""
1965+ mocks = {
1966+ 'log': mock.patch('charmhelpers.core.hookenv.log'),
1967+ 'relation_ids': mock.patch(
1968+ 'charmhelpers.core.hookenv.relation_ids',
1969+ mock.Mock(return_value=['rel-id'])),
1970+ 'relation_set': mock.patch(
1971+ 'charmhelpers.core.hookenv.relation_set'),
1972+ 'service_restart': mock.patch(
1973+ 'charmhelpers.core.host.service_restart'),
1974+ 'unit_get': mock.patch(
1975+ 'charmhelpers.core.hookenv.unit_get',
1976+ mock.Mock(return_value='1.2.3.4')),
1977+ 'write': mock.patch(
1978+ 'configfile.write',
1979+ mock.Mock(return_value=configuration_changed))
1980+ }
1981+ # Note: nested is deprecated for good reasons which do not apply here.
1982+ # Used here to easily nest a dynamically generated list of context
1983+ # managers.
1984+ with contextlib.nested(*mocks.values()) as context_managers:
1985+ object_dict = dict(zip(mocks.keys(), context_managers))
1986+ yield type('Mocks', (object,), object_dict)
1987+
1988+ def test_configuration_changed(self):
1989+ config = {
1990+ 'logfile': '/path/to/logfile',
1991+ 'loglevel': 'debug',
1992+ 'password': '',
1993+ 'port': 4242,
1994+ }
1995+ callback = serviceutils.write_config_file(config)
1996+ with self.patch_all(configuration_changed=True) as mocks:
1997+ callback('foo')
1998+ mocks.write.assert_called_once_with({
1999+ 'bind': '1.2.3.4',
2000+ 'logfile': '/path/to/logfile',
2001+ 'loglevel': 'debug',
2002+ 'port': 4242,
2003+ }, configfile.REDIS_CONF)
2004+ mocks.unit_get.assert_called_once_with('public-address')
2005+ mocks.service_restart.assert_called_once_with(
2006+ serviceutils.SERVICE_NAME)
2007+ self.assertEqual(3, mocks.log.call_count)
2008+ mocks.log.assert_has_calls([
2009+ mock.call('Retrieving service options.'),
2010+ mock.call('Writing configuration file for foo.'),
2011+ mock.call('Restarting service due to configuration change.')
2012+ ])
2013+
2014+ def test_configuration_changed_password(self):
2015+ config = {
2016+ 'logfile': '/path/to/logfile',
2017+ 'loglevel': 'debug',
2018+ 'password': 'secret!',
2019+ 'port': 4242,
2020+ }
2021+ callback = serviceutils.write_config_file(config)
2022+ with self.patch_all(configuration_changed=True) as mocks:
2023+ callback('foo')
2024+ mocks.write.assert_called_once_with({
2025+ 'bind': '1.2.3.4',
2026+ 'logfile': '/path/to/logfile',
2027+ 'loglevel': 'debug',
2028+ 'port': 4242,
2029+ 'requirepass': 'secret!',
2030+ }, configfile.REDIS_CONF)
2031+ mocks.unit_get.assert_called_once_with('public-address')
2032+ mocks.service_restart.assert_called_once_with(
2033+ serviceutils.SERVICE_NAME)
2034+
2035+ def test_configuration_changed_relations(self):
2036+ config = {
2037+ 'logfile': '/path/to/logfile',
2038+ 'loglevel': 'debug',
2039+ 'password': 'secret!',
2040+ 'port': 4242,
2041+ }
2042+ data = {
2043+ 'hostname': '4.3.2.1',
2044+ 'port': 90,
2045+ }
2046+ db_relation = make_relation(data)
2047+ callback = serviceutils.write_config_file(
2048+ config, db_relation=db_relation)
2049+ with self.patch_all(configuration_changed=True) as mocks:
2050+ callback('foo')
2051+ mocks.write.assert_called_once_with({
2052+ 'bind': '1.2.3.4',
2053+ 'logfile': '/path/to/logfile',
2054+ 'loglevel': 'debug',
2055+ 'port': 4242,
2056+ 'requirepass': 'secret!',
2057+ }, configfile.REDIS_CONF)
2058+ mocks.unit_get.assert_called_once_with('public-address')
2059+ mocks.service_restart.assert_called_once_with(
2060+ serviceutils.SERVICE_NAME)
2061+ mocks.relation_ids.assert_called_once_with('testing')
2062+ mocks.relation_set.assert_called_once_with('rel-id', data)
2063+
2064+ def test_configuration_unchanged_master(self):
2065+ config = {
2066+ 'logfile': '/path/to/logfile',
2067+ 'loglevel': 'debug',
2068+ 'password': '',
2069+ 'port': 4242,
2070+ }
2071+ callback = serviceutils.write_config_file(config)
2072+ with self.patch_all() as mocks:
2073+ callback('foo')
2074+ mocks.write.assert_called_once_with({
2075+ 'bind': '1.2.3.4',
2076+ 'logfile': '/path/to/logfile',
2077+ 'loglevel': 'debug',
2078+ 'port': 4242,
2079+ }, configfile.REDIS_CONF)
2080+ mocks.unit_get.assert_called_once_with('public-address')
2081+ self.assertFalse(mocks.service_restart.called)
2082+ self.assertEqual(3, mocks.log.call_count)
2083+ mocks.log.assert_has_calls([
2084+ mock.call('Retrieving service options.'),
2085+ mock.call('Writing configuration file for foo.'),
2086+ mock.call('No changes detected in the configuration file.')
2087+ ])
2088+
2089+ def test_configuration_unchanged_slave(self):
2090+ config = {
2091+ 'logfile': '/path/to/logs',
2092+ 'loglevel': 'info',
2093+ 'password': ' ',
2094+ 'port': 4242,
2095+ }
2096+ slave_relation = make_relation({
2097+ 'hostname': '4.3.2.1',
2098+ 'port': 4747,
2099+ })
2100+ callback = serviceutils.write_config_file(
2101+ config, slave_relation=slave_relation)
2102+ with self.patch_all() as mocks:
2103+ callback('foo')
2104+ mocks.write.assert_called_once_with({
2105+ 'bind': '1.2.3.4',
2106+ 'logfile': '/path/to/logs',
2107+ 'loglevel': 'info',
2108+ 'port': 4242,
2109+ 'slaveof': '4.3.2.1 4747'
2110+ }, configfile.REDIS_CONF)
2111+ mocks.unit_get.assert_called_once_with('public-address')
2112+ self.assertFalse(mocks.service_restart.called)
2113+ self.assertEqual(4, mocks.log.call_count)
2114+ mocks.log.assert_has_calls([
2115+ mock.call('Retrieving service options.'),
2116+ mock.call('Setting up slave relation.'),
2117+ mock.call('Writing configuration file for foo.'),
2118+ mock.call('No changes detected in the configuration file.')
2119+ ])
2120+
2121+ def test_configuration_unchanged_master_password(self):
2122+ config = {
2123+ 'logfile': '/path/to/logfile',
2124+ 'loglevel': 'debug',
2125+ 'password': 'secret!',
2126+ 'port': 42,
2127+ }
2128+ callback = serviceutils.write_config_file(config)
2129+ with self.patch_all() as mocks:
2130+ callback('foo')
2131+ mocks.write.assert_called_once_with({
2132+ 'bind': '1.2.3.4',
2133+ 'logfile': '/path/to/logfile',
2134+ 'loglevel': 'debug',
2135+ 'port': 42,
2136+ 'requirepass': 'secret!',
2137+ }, configfile.REDIS_CONF)
2138+ mocks.unit_get.assert_called_once_with('public-address')
2139+ self.assertFalse(mocks.service_restart.called)
2140+
2141+ def test_configuration_unchanged_slave_password(self):
2142+ config = {
2143+ 'logfile': '/path/to/logs',
2144+ 'loglevel': 'info',
2145+ 'password': '',
2146+ 'port': 4242,
2147+ }
2148+ slave_relation = make_relation({
2149+ 'hostname': '4.3.2.1',
2150+ 'password': 'sercret!',
2151+ 'port': 90,
2152+ })
2153+ callback = serviceutils.write_config_file(
2154+ config, slave_relation=slave_relation)
2155+ with self.patch_all() as mocks:
2156+ callback('foo')
2157+ mocks.write.assert_called_once_with({
2158+ 'bind': '1.2.3.4',
2159+ 'logfile': '/path/to/logs',
2160+ 'loglevel': 'info',
2161+ 'masterauth': 'sercret!',
2162+ 'port': 4242,
2163+ 'slaveof': '4.3.2.1 90'
2164+ }, configfile.REDIS_CONF)
2165+ mocks.unit_get.assert_called_once_with('public-address')
2166+ self.assertFalse(mocks.service_restart.called)

Subscribers

People subscribed via source and target branches