Merge lp:~1chb1n/charms/trusty/ubuntu/simplify-and-test into lp:charms/trusty/ubuntu

Proposed by Ryan Beisner
Status: Merged
Merged at revision: 11
Proposed branch: lp:~1chb1n/charms/trusty/ubuntu/simplify-and-test
Merge into: lp:charms/trusty/ubuntu
Diff against target: 4371 lines (+655/-3480)
41 files modified
Makefile (+57/-0)
README.md (+5/-21)
charm-helpers-hooks.yaml (+0/-5)
charm-helpers-tests.yaml (+4/-0)
config.yaml (+0/-8)
files/bundle-example.yaml (+14/-0)
hooks/charmhelpers/core/__init__.py (+0/-15)
hooks/charmhelpers/core/decorators.py (+0/-57)
hooks/charmhelpers/core/fstab.py (+0/-134)
hooks/charmhelpers/core/hookenv.py (+0/-568)
hooks/charmhelpers/core/host.py (+0/-446)
hooks/charmhelpers/core/services/__init__.py (+0/-18)
hooks/charmhelpers/core/services/base.py (+0/-329)
hooks/charmhelpers/core/services/helpers.py (+0/-259)
hooks/charmhelpers/core/strutils.py (+0/-42)
hooks/charmhelpers/core/sysctl.py (+0/-56)
hooks/charmhelpers/core/templating.py (+0/-68)
hooks/charmhelpers/core/unitdata.py (+0/-477)
hooks/charmhelpers/fetch/__init__.py (+0/-439)
hooks/charmhelpers/fetch/archiveurl.py (+0/-161)
hooks/charmhelpers/fetch/bzrurl.py (+0/-78)
hooks/charmhelpers/fetch/giturl.py (+0/-71)
hooks/config-changed (+0/-43)
hooks/hooks.py (+0/-45)
hooks/install (+0/-2)
hooks/utils.py (+0/-27)
metadata.yaml (+1/-1)
templates/lxc-bridge.conf (+0/-10)
tests/00-setup.sh (+4/-10)
tests/010_basic_precise (+7/-0)
tests/015_basic_trusty (+7/-0)
tests/020_basic_utopic (+7/-0)
tests/025_basic_vivid (+7/-0)
tests/10-deploy-test.py (+0/-90)
tests/basic_deployment.py (+49/-0)
tests/charmhelpers/__init__.py (+38/-0)
tests/charmhelpers/contrib/__init__.py (+15/-0)
tests/charmhelpers/contrib/amulet/__init__.py (+15/-0)
tests/charmhelpers/contrib/amulet/deployment.py (+93/-0)
tests/charmhelpers/contrib/amulet/utils.py (+323/-0)
tests/tests.yaml (+9/-0)
To merge this branch: bzr merge lp:~1chb1n/charms/trusty/ubuntu/simplify-and-test
Reviewer Review Type Date Requested Status
Tim Van Steenburgh (community) Approve
Review via email: mp+257773@code.launchpad.net

Commit message

Revert charm to have no hooks and no config options; add functional tests for all currently-supported Ubuntu releases; add bundletester usage examples.

Charm is now compatible with both 'juju test' and bundletester.

Description of the change

Revert charm to have no hooks and no config options; add functional tests for all currently-supported Ubuntu releases; add bundletester usage examples.

Charm is now compatible with both 'juju test' and bundletester.

To post a comment you must log in.
Revision history for this message
Ryan Beisner (1chb1n) wrote :

Tested via bundletester / amulet on Precise, Trusty, Utopic, Vivid. Ready for review.

Results: http://paste.ubuntu.com/10952530/

Revision history for this message
Marco Ceppi (marcoceppi) wrote :

See below

Revision history for this message
Ryan Beisner (1chb1n) wrote :

Thank you for the review. The bundle file is an example which won't be exercised in automation unless explicitly called. It accompanies the bundletester usage examples in the Makefile, which a user or charmer can optionally fire.

The juju-gui is present as a general use example, and as a handy way to fire up the most basic juju canvas. I think the Ubuntu charm is a good place for that, as well as these other usage examples which encourage easily adding functional test coverage. ;-)

To clarify, this MP aims to address 3 things:

1) Return the charm to a functional state on all supported releases.

2) Add test coverage for all supported releases, detect future breakage re: bug 1447765.

3) Add bundletester usage examples since we don't have that in one place anywhere else.

Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

+1 LGTM, thanks Ryan.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'Makefile'
--- Makefile 1970-01-01 00:00:00 +0000
+++ Makefile 2015-04-30 13:14:23 +0000
@@ -0,0 +1,57 @@
1#!/usr/bin/make
2PYTHON := /usr/bin/env python
3
4virtualenv:
5 @echo Setting up python virtual env...
6 virtualenv .venv
7 .venv/bin/pip install flake8 charm-tools bundletester
8
9lint: virtualenv
10 @echo Lint inspections and charm proof...
11 .venv/bin/flake8 --exclude hooks/charmhelpers tests
12 .venv/bin/charm proof
13
14test: virtualenv
15 @echo No unit tests defined. This is an example placeholder.
16
17functional_test: virtualenv
18 # Consume ./tests/tests.yaml to determine the tests to run,
19 # in addition to functional tests in the ./tests dir.
20 @echo Starting functional, lint and unit tests...
21 .venv/bin/bundletester -v -F -l DEBUG -r dot -o results-all.dot
22
23test_example: virtualenv
24 # A bundletester usage example to run only the matching tests.
25 @echo Starting a subset of tests...
26 .venv/bin/bundletester -v -F -l DEBUG -r json -o results-ex.json \
27 --test-pattern 02*
28
29test_example2: virtualenv
30 # A bundletester usage example to run only the specified tests,
31 # with a different output format.
32 @echo Starting a subset of tests...
33 .venv/bin/bundletester -v -F -l DEBUG -r spec -o results-ex2.spec \
34 010_basic_precise 015_basic_trusty
35
36test_example3: virtualenv
37 # A bundletester bundle usage example.
38 @echo Starting a subset of tests...
39 .venv/bin/bundletester -v -F -l DEBUG -r dot -o results-ex3.dot \
40 -b files/bundle-example.yaml
41
42bin/charm_helpers_sync.py:
43 @mkdir -p bin
44 @bzr cat lp:charm-helpers/tools/charm_helpers_sync/charm_helpers_sync.py \
45 > bin/charm_helpers_sync.py
46
47sync: bin/charm_helpers_sync.py
48 @echo Syncing charm helpers for functional tests...
49 @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers-tests.yaml
50
51publish: clean lint
52 bzr push lp:charms/trusty/ubuntu
53
54clean:
55 @ echo Cleaning up venvs and pyc files...
56 rm -rf .venv
57 find -name *.pyc -delete
058
=== modified file 'README.md'
--- README.md 2015-03-25 19:59:09 +0000
+++ README.md 2015-04-30 13:14:23 +0000
@@ -25,32 +25,16 @@
25 juju deploy ubuntu mytestmachine125 juju deploy ubuntu mytestmachine1
26 juju deploy ubuntu mytestmachine226 juju deploy ubuntu mytestmachine2
2727
28and so on.28and so on.
29
30# Configuration
31
32A configuration option to create a new network for LXC containers is provided via a boolean config option, 'new-lxc-network.'
33The default is set to false indicating the same network of the node will also be used for LXC containers.
34
35To use a new network for LXC containers post juju deploy, issue:
36
37 juju set ubuntu new-lxc-network=true
38
39If you want to have a new network for LXC containers on deploy set up a lxc-config.yaml:
40
41 ubuntu:
42 new-lxc-network: true
43
44and pass that as an arugment to deploy:
45
46 juju deploy --config lxc-config.yaml ubuntu
47
48For further information on setting configuration with Juju see https://jujucharms.com/docs/latest/charms-config
4929
50## Known Limitations and Issues30## Known Limitations and Issues
5131
52This charm does not provide anything other than a blank server, so it does not relate to other charms.32This charm does not provide anything other than a blank server, so it does not relate to other charms.
5333
34# Configuration
35
36This charm has no configuration options.
37
54# Contact Information38# Contact Information
5539
5640
5741
=== removed file 'charm-helpers-hooks.yaml'
--- charm-helpers-hooks.yaml 2015-02-19 16:21:31 +0000
+++ charm-helpers-hooks.yaml 1970-01-01 00:00:00 +0000
@@ -1,5 +0,0 @@
1branch: lp:charm-helpers
2destination: hooks/charmhelpers
3include:
4 - core
5 - fetch
60
=== added file 'charm-helpers-tests.yaml'
--- charm-helpers-tests.yaml 1970-01-01 00:00:00 +0000
+++ charm-helpers-tests.yaml 2015-04-30 13:14:23 +0000
@@ -0,0 +1,4 @@
1branch: lp:charm-helpers
2destination: tests/charmhelpers
3include:
4 - contrib.amulet
05
=== removed file 'config.yaml'
--- config.yaml 2015-03-25 19:59:09 +0000
+++ config.yaml 1970-01-01 00:00:00 +0000
@@ -1,8 +0,0 @@
1options:
2 new-lxc-network:
3 type: boolean
4 default: False
5 description: |
6 If True creates new network for lxc containers, otherwise using the
7 same network of the node for lxc containers.
8
90
=== added directory 'files'
=== added file 'files/bundle-example.yaml'
--- files/bundle-example.yaml 1970-01-01 00:00:00 +0000
+++ files/bundle-example.yaml 2015-04-30 13:14:23 +0000
@@ -0,0 +1,14 @@
1"ubuntu-example-bundle":
2 services:
3 "ubuntu":
4 charm: cs:trusty/ubuntu
5 num_units: 1
6 annotations:
7 "gui-x": "0"
8 "gui-y": "0"
9 "juju-gui":
10 charm: "cs:trusty/juju-gui"
11 num_units: 1
12 annotations:
13 "gui-x": "500"
14 "gui-y": "0"
015
=== removed directory 'hooks'
=== removed file 'hooks/__init__.py'
=== removed directory 'hooks/charmhelpers'
=== removed file 'hooks/charmhelpers/__init__.py'
=== removed directory 'hooks/charmhelpers/core'
=== removed file 'hooks/charmhelpers/core/__init__.py'
--- hooks/charmhelpers/core/__init__.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,15 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
160
=== removed file 'hooks/charmhelpers/core/decorators.py'
--- hooks/charmhelpers/core/decorators.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/decorators.py 1970-01-01 00:00:00 +0000
@@ -1,57 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17#
18# Copyright 2014 Canonical Ltd.
19#
20# Authors:
21# Edward Hope-Morley <opentastic@gmail.com>
22#
23
24import time
25
26from charmhelpers.core.hookenv import (
27 log,
28 INFO,
29)
30
31
32def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
33 """If the decorated function raises exception exc_type, allow num_retries
34 retry attempts before raise the exception.
35 """
36 def _retry_on_exception_inner_1(f):
37 def _retry_on_exception_inner_2(*args, **kwargs):
38 retries = num_retries
39 multiplier = 1
40 while True:
41 try:
42 return f(*args, **kwargs)
43 except exc_type:
44 if not retries:
45 raise
46
47 delay = base_delay * multiplier
48 multiplier += 1
49 log("Retrying '%s' %d more times (delay=%s)" %
50 (f.__name__, retries, delay), level=INFO)
51 retries -= 1
52 if delay:
53 time.sleep(delay)
54
55 return _retry_on_exception_inner_2
56
57 return _retry_on_exception_inner_1
580
=== removed file 'hooks/charmhelpers/core/fstab.py'
--- hooks/charmhelpers/core/fstab.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/fstab.py 1970-01-01 00:00:00 +0000
@@ -1,134 +0,0 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# This file is part of charm-helpers.
7#
8# charm-helpers is free software: you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License version 3 as
10# published by the Free Software Foundation.
11#
12# charm-helpers is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19
20import io
21import os
22
23__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
24
25
26class Fstab(io.FileIO):
27 """This class extends file in order to implement a file reader/writer
28 for file `/etc/fstab`
29 """
30
31 class Entry(object):
32 """Entry class represents a non-comment line on the `/etc/fstab` file
33 """
34 def __init__(self, device, mountpoint, filesystem,
35 options, d=0, p=0):
36 self.device = device
37 self.mountpoint = mountpoint
38 self.filesystem = filesystem
39
40 if not options:
41 options = "defaults"
42
43 self.options = options
44 self.d = int(d)
45 self.p = int(p)
46
47 def __eq__(self, o):
48 return str(self) == str(o)
49
50 def __str__(self):
51 return "{} {} {} {} {} {}".format(self.device,
52 self.mountpoint,
53 self.filesystem,
54 self.options,
55 self.d,
56 self.p)
57
58 DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
59
60 def __init__(self, path=None):
61 if path:
62 self._path = path
63 else:
64 self._path = self.DEFAULT_PATH
65 super(Fstab, self).__init__(self._path, 'rb+')
66
67 def _hydrate_entry(self, line):
68 # NOTE: use split with no arguments to split on any
69 # whitespace including tabs
70 return Fstab.Entry(*filter(
71 lambda x: x not in ('', None),
72 line.strip("\n").split()))
73
74 @property
75 def entries(self):
76 self.seek(0)
77 for line in self.readlines():
78 line = line.decode('us-ascii')
79 try:
80 if line.strip() and not line.strip().startswith("#"):
81 yield self._hydrate_entry(line)
82 except ValueError:
83 pass
84
85 def get_entry_by_attr(self, attr, value):
86 for entry in self.entries:
87 e_attr = getattr(entry, attr)
88 if e_attr == value:
89 return entry
90 return None
91
92 def add_entry(self, entry):
93 if self.get_entry_by_attr('device', entry.device):
94 return False
95
96 self.write((str(entry) + '\n').encode('us-ascii'))
97 self.truncate()
98 return entry
99
100 def remove_entry(self, entry):
101 self.seek(0)
102
103 lines = [l.decode('us-ascii') for l in self.readlines()]
104
105 found = False
106 for index, line in enumerate(lines):
107 if line.strip() and not line.strip().startswith("#"):
108 if self._hydrate_entry(line) == entry:
109 found = True
110 break
111
112 if not found:
113 return False
114
115 lines.remove(line)
116
117 self.seek(0)
118 self.write(''.join(lines).encode('us-ascii'))
119 self.truncate()
120 return True
121
122 @classmethod
123 def remove_by_mountpoint(cls, mountpoint, path=None):
124 fstab = cls(path=path)
125 entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
126 if entry:
127 return fstab.remove_entry(entry)
128 return False
129
130 @classmethod
131 def add(cls, device, mountpoint, filesystem, options=None, path=None):
132 return cls(path=path).add_entry(Fstab.Entry(device,
133 mountpoint, filesystem,
134 options=options))
1350
=== removed file 'hooks/charmhelpers/core/hookenv.py'
--- hooks/charmhelpers/core/hookenv.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/hookenv.py 1970-01-01 00:00:00 +0000
@@ -1,568 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17"Interactions with the Juju environment"
18# Copyright 2013 Canonical Ltd.
19#
20# Authors:
21# Charm Helpers Developers <juju@lists.ubuntu.com>
22
23import os
24import json
25import yaml
26import subprocess
27import sys
28from subprocess import CalledProcessError
29
30import six
31if not six.PY3:
32 from UserDict import UserDict
33else:
34 from collections import UserDict
35
36CRITICAL = "CRITICAL"
37ERROR = "ERROR"
38WARNING = "WARNING"
39INFO = "INFO"
40DEBUG = "DEBUG"
41MARKER = object()
42
43cache = {}
44
45
46def cached(func):
47 """Cache return values for multiple executions of func + args
48
49 For example::
50
51 @cached
52 def unit_get(attribute):
53 pass
54
55 unit_get('test')
56
57 will cache the result of unit_get + 'test' for future calls.
58 """
59 def wrapper(*args, **kwargs):
60 global cache
61 key = str((func, args, kwargs))
62 try:
63 return cache[key]
64 except KeyError:
65 res = func(*args, **kwargs)
66 cache[key] = res
67 return res
68 return wrapper
69
70
71def flush(key):
72 """Flushes any entries from function cache where the
73 key is found in the function+args """
74 flush_list = []
75 for item in cache:
76 if key in item:
77 flush_list.append(item)
78 for item in flush_list:
79 del cache[item]
80
81
82def log(message, level=None):
83 """Write a message to the juju log"""
84 command = ['juju-log']
85 if level:
86 command += ['-l', level]
87 if not isinstance(message, six.string_types):
88 message = repr(message)
89 command += [message]
90 subprocess.call(command)
91
92
93class Serializable(UserDict):
94 """Wrapper, an object that can be serialized to yaml or json"""
95
96 def __init__(self, obj):
97 # wrap the object
98 UserDict.__init__(self)
99 self.data = obj
100
101 def __getattr__(self, attr):
102 # See if this object has attribute.
103 if attr in ("json", "yaml", "data"):
104 return self.__dict__[attr]
105 # Check for attribute in wrapped object.
106 got = getattr(self.data, attr, MARKER)
107 if got is not MARKER:
108 return got
109 # Proxy to the wrapped object via dict interface.
110 try:
111 return self.data[attr]
112 except KeyError:
113 raise AttributeError(attr)
114
115 def __getstate__(self):
116 # Pickle as a standard dictionary.
117 return self.data
118
119 def __setstate__(self, state):
120 # Unpickle into our wrapper.
121 self.data = state
122
123 def json(self):
124 """Serialize the object to json"""
125 return json.dumps(self.data)
126
127 def yaml(self):
128 """Serialize the object to yaml"""
129 return yaml.dump(self.data)
130
131
132def execution_environment():
133 """A convenient bundling of the current execution context"""
134 context = {}
135 context['conf'] = config()
136 if relation_id():
137 context['reltype'] = relation_type()
138 context['relid'] = relation_id()
139 context['rel'] = relation_get()
140 context['unit'] = local_unit()
141 context['rels'] = relations()
142 context['env'] = os.environ
143 return context
144
145
146def in_relation_hook():
147 """Determine whether we're running in a relation hook"""
148 return 'JUJU_RELATION' in os.environ
149
150
151def relation_type():
152 """The scope for the current relation hook"""
153 return os.environ.get('JUJU_RELATION', None)
154
155
156def relation_id():
157 """The relation ID for the current relation hook"""
158 return os.environ.get('JUJU_RELATION_ID', None)
159
160
161def local_unit():
162 """Local unit ID"""
163 return os.environ['JUJU_UNIT_NAME']
164
165
166def remote_unit():
167 """The remote unit for the current relation hook"""
168 return os.environ['JUJU_REMOTE_UNIT']
169
170
171def service_name():
172 """The name service group this unit belongs to"""
173 return local_unit().split('/')[0]
174
175
176def hook_name():
177 """The name of the currently executing hook"""
178 return os.path.basename(sys.argv[0])
179
180
181class Config(dict):
182 """A dictionary representation of the charm's config.yaml, with some
183 extra features:
184
185 - See which values in the dictionary have changed since the previous hook.
186 - For values that have changed, see what the previous value was.
187 - Store arbitrary data for use in a later hook.
188
189 NOTE: Do not instantiate this object directly - instead call
190 ``hookenv.config()``, which will return an instance of :class:`Config`.
191
192 Example usage::
193
194 >>> # inside a hook
195 >>> from charmhelpers.core import hookenv
196 >>> config = hookenv.config()
197 >>> config['foo']
198 'bar'
199 >>> # store a new key/value for later use
200 >>> config['mykey'] = 'myval'
201
202
203 >>> # user runs `juju set mycharm foo=baz`
204 >>> # now we're inside subsequent config-changed hook
205 >>> config = hookenv.config()
206 >>> config['foo']
207 'baz'
208 >>> # test to see if this val has changed since last hook
209 >>> config.changed('foo')
210 True
211 >>> # what was the previous value?
212 >>> config.previous('foo')
213 'bar'
214 >>> # keys/values that we add are preserved across hooks
215 >>> config['mykey']
216 'myval'
217
218 """
219 CONFIG_FILE_NAME = '.juju-persistent-config'
220
221 def __init__(self, *args, **kw):
222 super(Config, self).__init__(*args, **kw)
223 self.implicit_save = True
224 self._prev_dict = None
225 self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
226 if os.path.exists(self.path):
227 self.load_previous()
228
229 def __getitem__(self, key):
230 """For regular dict lookups, check the current juju config first,
231 then the previous (saved) copy. This ensures that user-saved values
232 will be returned by a dict lookup.
233
234 """
235 try:
236 return dict.__getitem__(self, key)
237 except KeyError:
238 return (self._prev_dict or {})[key]
239
240 def keys(self):
241 prev_keys = []
242 if self._prev_dict is not None:
243 prev_keys = self._prev_dict.keys()
244 return list(set(prev_keys + list(dict.keys(self))))
245
246 def load_previous(self, path=None):
247 """Load previous copy of config from disk.
248
249 In normal usage you don't need to call this method directly - it
250 is called automatically at object initialization.
251
252 :param path:
253
254 File path from which to load the previous config. If `None`,
255 config is loaded from the default location. If `path` is
256 specified, subsequent `save()` calls will write to the same
257 path.
258
259 """
260 self.path = path or self.path
261 with open(self.path) as f:
262 self._prev_dict = json.load(f)
263
264 def changed(self, key):
265 """Return True if the current value for this key is different from
266 the previous value.
267
268 """
269 if self._prev_dict is None:
270 return True
271 return self.previous(key) != self.get(key)
272
273 def previous(self, key):
274 """Return previous value for this key, or None if there
275 is no previous value.
276
277 """
278 if self._prev_dict:
279 return self._prev_dict.get(key)
280 return None
281
282 def save(self):
283 """Save this config to disk.
284
285 If the charm is using the :mod:`Services Framework <services.base>`
286 or :meth:'@hook <Hooks.hook>' decorator, this
287 is called automatically at the end of successful hook execution.
288 Otherwise, it should be called directly by user code.
289
290 To disable automatic saves, set ``implicit_save=False`` on this
291 instance.
292
293 """
294 if self._prev_dict:
295 for k, v in six.iteritems(self._prev_dict):
296 if k not in self:
297 self[k] = v
298 with open(self.path, 'w') as f:
299 json.dump(self, f)
300
301
302@cached
303def config(scope=None):
304 """Juju charm configuration"""
305 config_cmd_line = ['config-get']
306 if scope is not None:
307 config_cmd_line.append(scope)
308 config_cmd_line.append('--format=json')
309 try:
310 config_data = json.loads(
311 subprocess.check_output(config_cmd_line).decode('UTF-8'))
312 if scope is not None:
313 return config_data
314 return Config(config_data)
315 except ValueError:
316 return None
317
318
319@cached
320def relation_get(attribute=None, unit=None, rid=None):
321 """Get relation information"""
322 _args = ['relation-get', '--format=json']
323 if rid:
324 _args.append('-r')
325 _args.append(rid)
326 _args.append(attribute or '-')
327 if unit:
328 _args.append(unit)
329 try:
330 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
331 except ValueError:
332 return None
333 except CalledProcessError as e:
334 if e.returncode == 2:
335 return None
336 raise
337
338
339def relation_set(relation_id=None, relation_settings=None, **kwargs):
340 """Set relation information for the current unit"""
341 relation_settings = relation_settings if relation_settings else {}
342 relation_cmd_line = ['relation-set']
343 if relation_id is not None:
344 relation_cmd_line.extend(('-r', relation_id))
345 for k, v in (list(relation_settings.items()) + list(kwargs.items())):
346 if v is None:
347 relation_cmd_line.append('{}='.format(k))
348 else:
349 relation_cmd_line.append('{}={}'.format(k, v))
350 subprocess.check_call(relation_cmd_line)
351 # Flush cache of any relation-gets for local unit
352 flush(local_unit())
353
354
355@cached
356def relation_ids(reltype=None):
357 """A list of relation_ids"""
358 reltype = reltype or relation_type()
359 relid_cmd_line = ['relation-ids', '--format=json']
360 if reltype is not None:
361 relid_cmd_line.append(reltype)
362 return json.loads(
363 subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
364 return []
365
366
367@cached
368def related_units(relid=None):
369 """A list of related units"""
370 relid = relid or relation_id()
371 units_cmd_line = ['relation-list', '--format=json']
372 if relid is not None:
373 units_cmd_line.extend(('-r', relid))
374 return json.loads(
375 subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
376
377
378@cached
379def relation_for_unit(unit=None, rid=None):
380 """Get the json represenation of a unit's relation"""
381 unit = unit or remote_unit()
382 relation = relation_get(unit=unit, rid=rid)
383 for key in relation:
384 if key.endswith('-list'):
385 relation[key] = relation[key].split()
386 relation['__unit__'] = unit
387 return relation
388
389
390@cached
391def relations_for_id(relid=None):
392 """Get relations of a specific relation ID"""
393 relation_data = []
394 relid = relid or relation_ids()
395 for unit in related_units(relid):
396 unit_data = relation_for_unit(unit, relid)
397 unit_data['__relid__'] = relid
398 relation_data.append(unit_data)
399 return relation_data
400
401
402@cached
403def relations_of_type(reltype=None):
404 """Get relations of a specific type"""
405 relation_data = []
406 reltype = reltype or relation_type()
407 for relid in relation_ids(reltype):
408 for relation in relations_for_id(relid):
409 relation['__relid__'] = relid
410 relation_data.append(relation)
411 return relation_data
412
413
414@cached
415def metadata():
416 """Get the current charm metadata.yaml contents as a python object"""
417 with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
418 return yaml.safe_load(md)
419
420
421@cached
422def relation_types():
423 """Get a list of relation types supported by this charm"""
424 rel_types = []
425 md = metadata()
426 for key in ('provides', 'requires', 'peers'):
427 section = md.get(key)
428 if section:
429 rel_types.extend(section.keys())
430 return rel_types
431
432
433@cached
434def charm_name():
435 """Get the name of the current charm as is specified on metadata.yaml"""
436 return metadata().get('name')
437
438
439@cached
440def relations():
441 """Get a nested dictionary of relation data for all related units"""
442 rels = {}
443 for reltype in relation_types():
444 relids = {}
445 for relid in relation_ids(reltype):
446 units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
447 for unit in related_units(relid):
448 reldata = relation_get(unit=unit, rid=relid)
449 units[unit] = reldata
450 relids[relid] = units
451 rels[reltype] = relids
452 return rels
453
454
455@cached
456def is_relation_made(relation, keys='private-address'):
457 '''
458 Determine whether a relation is established by checking for
459 presence of key(s). If a list of keys is provided, they
460 must all be present for the relation to be identified as made
461 '''
462 if isinstance(keys, str):
463 keys = [keys]
464 for r_id in relation_ids(relation):
465 for unit in related_units(r_id):
466 context = {}
467 for k in keys:
468 context[k] = relation_get(k, rid=r_id,
469 unit=unit)
470 if None not in context.values():
471 return True
472 return False
473
474
475def open_port(port, protocol="TCP"):
476 """Open a service network port"""
477 _args = ['open-port']
478 _args.append('{}/{}'.format(port, protocol))
479 subprocess.check_call(_args)
480
481
482def close_port(port, protocol="TCP"):
483 """Close a service network port"""
484 _args = ['close-port']
485 _args.append('{}/{}'.format(port, protocol))
486 subprocess.check_call(_args)
487
488
489@cached
490def unit_get(attribute):
491 """Get the unit ID for the remote unit"""
492 _args = ['unit-get', '--format=json', attribute]
493 try:
494 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
495 except ValueError:
496 return None
497
498
499def unit_private_ip():
500 """Get this unit's private IP address"""
501 return unit_get('private-address')
502
503
504class UnregisteredHookError(Exception):
505 """Raised when an undefined hook is called"""
506 pass
507
508
509class Hooks(object):
510 """A convenient handler for hook functions.
511
512 Example::
513
514 hooks = Hooks()
515
516 # register a hook, taking its name from the function name
517 @hooks.hook()
518 def install():
519 pass # your code here
520
521 # register a hook, providing a custom hook name
522 @hooks.hook("config-changed")
523 def config_changed():
524 pass # your code here
525
526 if __name__ == "__main__":
527 # execute a hook based on the name the program is called by
528 hooks.execute(sys.argv)
529 """
530
531 def __init__(self, config_save=True):
532 super(Hooks, self).__init__()
533 self._hooks = {}
534 self._config_save = config_save
535
536 def register(self, name, function):
537 """Register a hook"""
538 self._hooks[name] = function
539
540 def execute(self, args):
541 """Execute a registered hook based on args[0]"""
542 hook_name = os.path.basename(args[0])
543 if hook_name in self._hooks:
544 self._hooks[hook_name]()
545 if self._config_save:
546 cfg = config()
547 if cfg.implicit_save:
548 cfg.save()
549 else:
550 raise UnregisteredHookError(hook_name)
551
552 def hook(self, *hook_names):
553 """Decorator, registering them as hooks"""
554 def wrapper(decorated):
555 for hook_name in hook_names:
556 self.register(hook_name, decorated)
557 else:
558 self.register(decorated.__name__, decorated)
559 if '_' in decorated.__name__:
560 self.register(
561 decorated.__name__.replace('_', '-'), decorated)
562 return decorated
563 return wrapper
564
565
566def charm_dir():
567 """Return the root directory of the current charm"""
568 return os.environ.get('CHARM_DIR')
5690
=== removed file 'hooks/charmhelpers/core/host.py'
--- hooks/charmhelpers/core/host.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/host.py 1970-01-01 00:00:00 +0000
@@ -1,446 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tools for working with the host system"""
18# Copyright 2012 Canonical Ltd.
19#
20# Authors:
21# Nick Moffitt <nick.moffitt@canonical.com>
22# Matthew Wedgwood <matthew.wedgwood@canonical.com>
23
24import os
25import re
26import pwd
27import grp
28import random
29import string
30import subprocess
31import hashlib
32from contextlib import contextmanager
33from collections import OrderedDict
34
35import six
36
37from .hookenv import log
38from .fstab import Fstab
39
40
41def service_start(service_name):
42 """Start a system service"""
43 return service('start', service_name)
44
45
46def service_stop(service_name):
47 """Stop a system service"""
48 return service('stop', service_name)
49
50
51def service_restart(service_name):
52 """Restart a system service"""
53 return service('restart', service_name)
54
55
56def service_reload(service_name, restart_on_failure=False):
57 """Reload a system service, optionally falling back to restart if
58 reload fails"""
59 service_result = service('reload', service_name)
60 if not service_result and restart_on_failure:
61 service_result = service('restart', service_name)
62 return service_result
63
64
65def service(action, service_name):
66 """Control a system service"""
67 cmd = ['service', service_name, action]
68 return subprocess.call(cmd) == 0
69
70
71def service_running(service):
72 """Determine whether a system service is running"""
73 try:
74 output = subprocess.check_output(
75 ['service', service, 'status'],
76 stderr=subprocess.STDOUT).decode('UTF-8')
77 except subprocess.CalledProcessError:
78 return False
79 else:
80 if ("start/running" in output or "is running" in output):
81 return True
82 else:
83 return False
84
85
86def service_available(service_name):
87 """Determine whether a system service is available"""
88 try:
89 subprocess.check_output(
90 ['service', service_name, 'status'],
91 stderr=subprocess.STDOUT).decode('UTF-8')
92 except subprocess.CalledProcessError as e:
93 return 'unrecognized service' not in e.output
94 else:
95 return True
96
97
98def adduser(username, password=None, shell='/bin/bash', system_user=False):
99 """Add a user to the system"""
100 try:
101 user_info = pwd.getpwnam(username)
102 log('user {0} already exists!'.format(username))
103 except KeyError:
104 log('creating user {0}'.format(username))
105 cmd = ['useradd']
106 if system_user or password is None:
107 cmd.append('--system')
108 else:
109 cmd.extend([
110 '--create-home',
111 '--shell', shell,
112 '--password', password,
113 ])
114 cmd.append(username)
115 subprocess.check_call(cmd)
116 user_info = pwd.getpwnam(username)
117 return user_info
118
119
120def add_group(group_name, system_group=False):
121 """Add a group to the system"""
122 try:
123 group_info = grp.getgrnam(group_name)
124 log('group {0} already exists!'.format(group_name))
125 except KeyError:
126 log('creating group {0}'.format(group_name))
127 cmd = ['addgroup']
128 if system_group:
129 cmd.append('--system')
130 else:
131 cmd.extend([
132 '--group',
133 ])
134 cmd.append(group_name)
135 subprocess.check_call(cmd)
136 group_info = grp.getgrnam(group_name)
137 return group_info
138
139
140def add_user_to_group(username, group):
141 """Add a user to a group"""
142 cmd = [
143 'gpasswd', '-a',
144 username,
145 group
146 ]
147 log("Adding user {} to group {}".format(username, group))
148 subprocess.check_call(cmd)
149
150
151def rsync(from_path, to_path, flags='-r', options=None):
152 """Replicate the contents of a path"""
153 options = options or ['--delete', '--executability']
154 cmd = ['/usr/bin/rsync', flags]
155 cmd.extend(options)
156 cmd.append(from_path)
157 cmd.append(to_path)
158 log(" ".join(cmd))
159 return subprocess.check_output(cmd).decode('UTF-8').strip()
160
161
162def symlink(source, destination):
163 """Create a symbolic link"""
164 log("Symlinking {} as {}".format(source, destination))
165 cmd = [
166 'ln',
167 '-sf',
168 source,
169 destination,
170 ]
171 subprocess.check_call(cmd)
172
173
174def mkdir(path, owner='root', group='root', perms=0o555, force=False):
175 """Create a directory"""
176 log("Making dir {} {}:{} {:o}".format(path, owner, group,
177 perms))
178 uid = pwd.getpwnam(owner).pw_uid
179 gid = grp.getgrnam(group).gr_gid
180 realpath = os.path.abspath(path)
181 path_exists = os.path.exists(realpath)
182 if path_exists and force:
183 if not os.path.isdir(realpath):
184 log("Removing non-directory file {} prior to mkdir()".format(path))
185 os.unlink(realpath)
186 os.makedirs(realpath, perms)
187 elif not path_exists:
188 os.makedirs(realpath, perms)
189 os.chown(realpath, uid, gid)
190 os.chmod(realpath, perms)
191
192
193def write_file(path, content, owner='root', group='root', perms=0o444):
194 """Create or overwrite a file with the contents of a byte string."""
195 log("Writing file {} {}:{} {:o}".format(path, owner, group, perms))
196 uid = pwd.getpwnam(owner).pw_uid
197 gid = grp.getgrnam(group).gr_gid
198 with open(path, 'wb') as target:
199 os.fchown(target.fileno(), uid, gid)
200 os.fchmod(target.fileno(), perms)
201 target.write(content)
202
203
204def fstab_remove(mp):
205 """Remove the given mountpoint entry from /etc/fstab
206 """
207 return Fstab.remove_by_mountpoint(mp)
208
209
210def fstab_add(dev, mp, fs, options=None):
211 """Adds the given device entry to the /etc/fstab file
212 """
213 return Fstab.add(dev, mp, fs, options=options)
214
215
216def mount(device, mountpoint, options=None, persist=False, filesystem="ext3"):
217 """Mount a filesystem at a particular mountpoint"""
218 cmd_args = ['mount']
219 if options is not None:
220 cmd_args.extend(['-o', options])
221 cmd_args.extend([device, mountpoint])
222 try:
223 subprocess.check_output(cmd_args)
224 except subprocess.CalledProcessError as e:
225 log('Error mounting {} at {}\n{}'.format(device, mountpoint, e.output))
226 return False
227
228 if persist:
229 return fstab_add(device, mountpoint, filesystem, options=options)
230 return True
231
232
233def umount(mountpoint, persist=False):
234 """Unmount a filesystem"""
235 cmd_args = ['umount', mountpoint]
236 try:
237 subprocess.check_output(cmd_args)
238 except subprocess.CalledProcessError as e:
239 log('Error unmounting {}\n{}'.format(mountpoint, e.output))
240 return False
241
242 if persist:
243 return fstab_remove(mountpoint)
244 return True
245
246
247def mounts():
248 """Get a list of all mounted volumes as [[mountpoint,device],[...]]"""
249 with open('/proc/mounts') as f:
250 # [['/mount/point','/dev/path'],[...]]
251 system_mounts = [m[1::-1] for m in [l.strip().split()
252 for l in f.readlines()]]
253 return system_mounts
254
255
256def file_hash(path, hash_type='md5'):
257 """
258 Generate a hash checksum of the contents of 'path' or None if not found.
259
260 :param str hash_type: Any hash alrgorithm supported by :mod:`hashlib`,
261 such as md5, sha1, sha256, sha512, etc.
262 """
263 if os.path.exists(path):
264 h = getattr(hashlib, hash_type)()
265 with open(path, 'rb') as source:
266 h.update(source.read())
267 return h.hexdigest()
268 else:
269 return None
270
271
272def check_hash(path, checksum, hash_type='md5'):
273 """
274 Validate a file using a cryptographic checksum.
275
276 :param str checksum: Value of the checksum used to validate the file.
277 :param str hash_type: Hash algorithm used to generate `checksum`.
278 Can be any hash alrgorithm supported by :mod:`hashlib`,
279 such as md5, sha1, sha256, sha512, etc.
280 :raises ChecksumError: If the file fails the checksum
281
282 """
283 actual_checksum = file_hash(path, hash_type)
284 if checksum != actual_checksum:
285 raise ChecksumError("'%s' != '%s'" % (checksum, actual_checksum))
286
287
288class ChecksumError(ValueError):
289 pass
290
291
292def restart_on_change(restart_map, stopstart=False):
293 """Restart services based on configuration files changing
294
295 This function is used a decorator, for example::
296
297 @restart_on_change({
298 '/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
299 })
300 def ceph_client_changed():
301 pass # your code here
302
303 In this example, the cinder-api and cinder-volume services
304 would be restarted if /etc/ceph/ceph.conf is changed by the
305 ceph_client_changed function.
306 """
307 def wrap(f):
308 def wrapped_f(*args, **kwargs):
309 checksums = {}
310 for path in restart_map:
311 checksums[path] = file_hash(path)
312 f(*args, **kwargs)
313 restarts = []
314 for path in restart_map:
315 if checksums[path] != file_hash(path):
316 restarts += restart_map[path]
317 services_list = list(OrderedDict.fromkeys(restarts))
318 if not stopstart:
319 for service_name in services_list:
320 service('restart', service_name)
321 else:
322 for action in ['stop', 'start']:
323 for service_name in services_list:
324 service(action, service_name)
325 return wrapped_f
326 return wrap
327
328
329def lsb_release():
330 """Return /etc/lsb-release in a dict"""
331 d = {}
332 with open('/etc/lsb-release', 'r') as lsb:
333 for l in lsb:
334 k, v = l.split('=')
335 d[k.strip()] = v.strip()
336 return d
337
338
339def pwgen(length=None):
340 """Generate a random pasword."""
341 if length is None:
342 length = random.choice(range(35, 45))
343 alphanumeric_chars = [
344 l for l in (string.ascii_letters + string.digits)
345 if l not in 'l0QD1vAEIOUaeiou']
346 random_chars = [
347 random.choice(alphanumeric_chars) for _ in range(length)]
348 return(''.join(random_chars))
349
350
351def list_nics(nic_type):
352 '''Return a list of nics of given type(s)'''
353 if isinstance(nic_type, six.string_types):
354 int_types = [nic_type]
355 else:
356 int_types = nic_type
357 interfaces = []
358 for int_type in int_types:
359 cmd = ['ip', 'addr', 'show', 'label', int_type + '*']
360 ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
361 ip_output = (line for line in ip_output if line)
362 for line in ip_output:
363 if line.split()[1].startswith(int_type):
364 matched = re.search('.*: (' + int_type + r'[0-9]+\.[0-9]+)@.*', line)
365 if matched:
366 interface = matched.groups()[0]
367 else:
368 interface = line.split()[1].replace(":", "")
369 interfaces.append(interface)
370
371 return interfaces
372
373
374def set_nic_mtu(nic, mtu):
375 '''Set MTU on a network interface'''
376 cmd = ['ip', 'link', 'set', nic, 'mtu', mtu]
377 subprocess.check_call(cmd)
378
379
380def get_nic_mtu(nic):
381 cmd = ['ip', 'addr', 'show', nic]
382 ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
383 mtu = ""
384 for line in ip_output:
385 words = line.split()
386 if 'mtu' in words:
387 mtu = words[words.index("mtu") + 1]
388 return mtu
389
390
391def get_nic_hwaddr(nic):
392 cmd = ['ip', '-o', '-0', 'addr', 'show', nic]
393 ip_output = subprocess.check_output(cmd).decode('UTF-8')
394 hwaddr = ""
395 words = ip_output.split()
396 if 'link/ether' in words:
397 hwaddr = words[words.index('link/ether') + 1]
398 return hwaddr
399
400
401def cmp_pkgrevno(package, revno, pkgcache=None):
402 '''Compare supplied revno with the revno of the installed package
403
404 * 1 => Installed revno is greater than supplied arg
405 * 0 => Installed revno is the same as supplied arg
406 * -1 => Installed revno is less than supplied arg
407
408 This function imports apt_cache function from charmhelpers.fetch if
409 the pkgcache argument is None. Be sure to add charmhelpers.fetch if
410 you call this function, or pass an apt_pkg.Cache() instance.
411 '''
412 import apt_pkg
413 if not pkgcache:
414 from charmhelpers.fetch import apt_cache
415 pkgcache = apt_cache()
416 pkg = pkgcache[package]
417 return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
418
419
420@contextmanager
421def chdir(d):
422 cur = os.getcwd()
423 try:
424 yield os.chdir(d)
425 finally:
426 os.chdir(cur)
427
428
429def chownr(path, owner, group, follow_links=True):
430 uid = pwd.getpwnam(owner).pw_uid
431 gid = grp.getgrnam(group).gr_gid
432 if follow_links:
433 chown = os.chown
434 else:
435 chown = os.lchown
436
437 for root, dirs, files in os.walk(path):
438 for name in dirs + files:
439 full = os.path.join(root, name)
440 broken_symlink = os.path.lexists(full) and not os.path.exists(full)
441 if not broken_symlink:
442 chown(full, uid, gid)
443
444
445def lchownr(path, owner, group):
446 chownr(path, owner, group, follow_links=False)
4470
=== removed directory 'hooks/charmhelpers/core/services'
=== removed file 'hooks/charmhelpers/core/services/__init__.py'
--- hooks/charmhelpers/core/services/__init__.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/services/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,18 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17from .base import * # NOQA
18from .helpers import * # NOQA
190
=== removed file 'hooks/charmhelpers/core/services/base.py'
--- hooks/charmhelpers/core/services/base.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/services/base.py 1970-01-01 00:00:00 +0000
@@ -1,329 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18import re
19import json
20from collections import Iterable
21
22from charmhelpers.core import host
23from charmhelpers.core import hookenv
24
25
26__all__ = ['ServiceManager', 'ManagerCallback',
27 'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
28 'service_restart', 'service_stop']
29
30
31class ServiceManager(object):
32 def __init__(self, services=None):
33 """
34 Register a list of services, given their definitions.
35
36 Service definitions are dicts in the following formats (all keys except
37 'service' are optional)::
38
39 {
40 "service": <service name>,
41 "required_data": <list of required data contexts>,
42 "provided_data": <list of provided data contexts>,
43 "data_ready": <one or more callbacks>,
44 "data_lost": <one or more callbacks>,
45 "start": <one or more callbacks>,
46 "stop": <one or more callbacks>,
47 "ports": <list of ports to manage>,
48 }
49
50 The 'required_data' list should contain dicts of required data (or
51 dependency managers that act like dicts and know how to collect the data).
52 Only when all items in the 'required_data' list are populated are the list
53 of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
54 information.
55
56 The 'provided_data' list should contain relation data providers, most likely
57 a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
58 that will indicate a set of data to set on a given relation.
59
60 The 'data_ready' value should be either a single callback, or a list of
61 callbacks, to be called when all items in 'required_data' pass `is_ready()`.
62 Each callback will be called with the service name as the only parameter.
63 After all of the 'data_ready' callbacks are called, the 'start' callbacks
64 are fired.
65
66 The 'data_lost' value should be either a single callback, or a list of
67 callbacks, to be called when a 'required_data' item no longer passes
68 `is_ready()`. Each callback will be called with the service name as the
69 only parameter. After all of the 'data_lost' callbacks are called,
70 the 'stop' callbacks are fired.
71
72 The 'start' value should be either a single callback, or a list of
73 callbacks, to be called when starting the service, after the 'data_ready'
74 callbacks are complete. Each callback will be called with the service
75 name as the only parameter. This defaults to
76 `[host.service_start, services.open_ports]`.
77
78 The 'stop' value should be either a single callback, or a list of
79 callbacks, to be called when stopping the service. If the service is
80 being stopped because it no longer has all of its 'required_data', this
81 will be called after all of the 'data_lost' callbacks are complete.
82 Each callback will be called with the service name as the only parameter.
83 This defaults to `[services.close_ports, host.service_stop]`.
84
85 The 'ports' value should be a list of ports to manage. The default
86 'start' handler will open the ports after the service is started,
87 and the default 'stop' handler will close the ports prior to stopping
88 the service.
89
90
91 Examples:
92
93 The following registers an Upstart service called bingod that depends on
94 a mongodb relation and which runs a custom `db_migrate` function prior to
95 restarting the service, and a Runit service called spadesd::
96
97 manager = services.ServiceManager([
98 {
99 'service': 'bingod',
100 'ports': [80, 443],
101 'required_data': [MongoRelation(), config(), {'my': 'data'}],
102 'data_ready': [
103 services.template(source='bingod.conf'),
104 services.template(source='bingod.ini',
105 target='/etc/bingod.ini',
106 owner='bingo', perms=0400),
107 ],
108 },
109 {
110 'service': 'spadesd',
111 'data_ready': services.template(source='spadesd_run.j2',
112 target='/etc/sv/spadesd/run',
113 perms=0555),
114 'start': runit_start,
115 'stop': runit_stop,
116 },
117 ])
118 manager.manage()
119 """
120 self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
121 self._ready = None
122 self.services = {}
123 for service in services or []:
124 service_name = service['service']
125 self.services[service_name] = service
126
127 def manage(self):
128 """
129 Handle the current hook by doing The Right Thing with the registered services.
130 """
131 hook_name = hookenv.hook_name()
132 if hook_name == 'stop':
133 self.stop_services()
134 else:
135 self.provide_data()
136 self.reconfigure_services()
137 cfg = hookenv.config()
138 if cfg.implicit_save:
139 cfg.save()
140
141 def provide_data(self):
142 """
143 Set the relation data for each provider in the ``provided_data`` list.
144
145 A provider must have a `name` attribute, which indicates which relation
146 to set data on, and a `provide_data()` method, which returns a dict of
147 data to set.
148 """
149 hook_name = hookenv.hook_name()
150 for service in self.services.values():
151 for provider in service.get('provided_data', []):
152 if re.match(r'{}-relation-(joined|changed)'.format(provider.name), hook_name):
153 data = provider.provide_data()
154 _ready = provider._is_ready(data) if hasattr(provider, '_is_ready') else data
155 if _ready:
156 hookenv.relation_set(None, data)
157
158 def reconfigure_services(self, *service_names):
159 """
160 Update all files for one or more registered services, and,
161 if ready, optionally restart them.
162
163 If no service names are given, reconfigures all registered services.
164 """
165 for service_name in service_names or self.services.keys():
166 if self.is_ready(service_name):
167 self.fire_event('data_ready', service_name)
168 self.fire_event('start', service_name, default=[
169 service_restart,
170 manage_ports])
171 self.save_ready(service_name)
172 else:
173 if self.was_ready(service_name):
174 self.fire_event('data_lost', service_name)
175 self.fire_event('stop', service_name, default=[
176 manage_ports,
177 service_stop])
178 self.save_lost(service_name)
179
180 def stop_services(self, *service_names):
181 """
182 Stop one or more registered services, by name.
183
184 If no service names are given, stops all registered services.
185 """
186 for service_name in service_names or self.services.keys():
187 self.fire_event('stop', service_name, default=[
188 manage_ports,
189 service_stop])
190
191 def get_service(self, service_name):
192 """
193 Given the name of a registered service, return its service definition.
194 """
195 service = self.services.get(service_name)
196 if not service:
197 raise KeyError('Service not registered: %s' % service_name)
198 return service
199
200 def fire_event(self, event_name, service_name, default=None):
201 """
202 Fire a data_ready, data_lost, start, or stop event on a given service.
203 """
204 service = self.get_service(service_name)
205 callbacks = service.get(event_name, default)
206 if not callbacks:
207 return
208 if not isinstance(callbacks, Iterable):
209 callbacks = [callbacks]
210 for callback in callbacks:
211 if isinstance(callback, ManagerCallback):
212 callback(self, service_name, event_name)
213 else:
214 callback(service_name)
215
216 def is_ready(self, service_name):
217 """
218 Determine if a registered service is ready, by checking its 'required_data'.
219
220 A 'required_data' item can be any mapping type, and is considered ready
221 if `bool(item)` evaluates as True.
222 """
223 service = self.get_service(service_name)
224 reqs = service.get('required_data', [])
225 return all(bool(req) for req in reqs)
226
227 def _load_ready_file(self):
228 if self._ready is not None:
229 return
230 if os.path.exists(self._ready_file):
231 with open(self._ready_file) as fp:
232 self._ready = set(json.load(fp))
233 else:
234 self._ready = set()
235
236 def _save_ready_file(self):
237 if self._ready is None:
238 return
239 with open(self._ready_file, 'w') as fp:
240 json.dump(list(self._ready), fp)
241
242 def save_ready(self, service_name):
243 """
244 Save an indicator that the given service is now data_ready.
245 """
246 self._load_ready_file()
247 self._ready.add(service_name)
248 self._save_ready_file()
249
250 def save_lost(self, service_name):
251 """
252 Save an indicator that the given service is no longer data_ready.
253 """
254 self._load_ready_file()
255 self._ready.discard(service_name)
256 self._save_ready_file()
257
258 def was_ready(self, service_name):
259 """
260 Determine if the given service was previously data_ready.
261 """
262 self._load_ready_file()
263 return service_name in self._ready
264
265
266class ManagerCallback(object):
267 """
268 Special case of a callback that takes the `ServiceManager` instance
269 in addition to the service name.
270
271 Subclasses should implement `__call__` which should accept three parameters:
272
273 * `manager` The `ServiceManager` instance
274 * `service_name` The name of the service it's being triggered for
275 * `event_name` The name of the event that this callback is handling
276 """
277 def __call__(self, manager, service_name, event_name):
278 raise NotImplementedError()
279
280
281class PortManagerCallback(ManagerCallback):
282 """
283 Callback class that will open or close ports, for use as either
284 a start or stop action.
285 """
286 def __call__(self, manager, service_name, event_name):
287 service = manager.get_service(service_name)
288 new_ports = service.get('ports', [])
289 port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
290 if os.path.exists(port_file):
291 with open(port_file) as fp:
292 old_ports = fp.read().split(',')
293 for old_port in old_ports:
294 if bool(old_port):
295 old_port = int(old_port)
296 if old_port not in new_ports:
297 hookenv.close_port(old_port)
298 with open(port_file, 'w') as fp:
299 fp.write(','.join(str(port) for port in new_ports))
300 for port in new_ports:
301 if event_name == 'start':
302 hookenv.open_port(port)
303 elif event_name == 'stop':
304 hookenv.close_port(port)
305
306
307def service_stop(service_name):
308 """
309 Wrapper around host.service_stop to prevent spurious "unknown service"
310 messages in the logs.
311 """
312 if host.service_running(service_name):
313 host.service_stop(service_name)
314
315
316def service_restart(service_name):
317 """
318 Wrapper around host.service_restart to prevent spurious "unknown service"
319 messages in the logs.
320 """
321 if host.service_available(service_name):
322 if host.service_running(service_name):
323 host.service_restart(service_name)
324 else:
325 host.service_start(service_name)
326
327
328# Convenience aliases
329open_ports = close_ports = manage_ports = PortManagerCallback()
3300
=== removed file 'hooks/charmhelpers/core/services/helpers.py'
--- hooks/charmhelpers/core/services/helpers.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/services/helpers.py 1970-01-01 00:00:00 +0000
@@ -1,259 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18import yaml
19from charmhelpers.core import hookenv
20from charmhelpers.core import templating
21
22from charmhelpers.core.services.base import ManagerCallback
23
24
25__all__ = ['RelationContext', 'TemplateCallback',
26 'render_template', 'template']
27
28
29class RelationContext(dict):
30 """
31 Base class for a context generator that gets relation data from juju.
32
33 Subclasses must provide the attributes `name`, which is the name of the
34 interface of interest, `interface`, which is the type of the interface of
35 interest, and `required_keys`, which is the set of keys required for the
36 relation to be considered complete. The data for all interfaces matching
37 the `name` attribute that are complete will used to populate the dictionary
38 values (see `get_data`, below).
39
40 The generated context will be namespaced under the relation :attr:`name`,
41 to prevent potential naming conflicts.
42
43 :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
44 :param list additional_required_keys: Extend the list of :attr:`required_keys`
45 """
46 name = None
47 interface = None
48 required_keys = []
49
50 def __init__(self, name=None, additional_required_keys=None):
51 if name is not None:
52 self.name = name
53 if additional_required_keys is not None:
54 self.required_keys.extend(additional_required_keys)
55 self.get_data()
56
57 def __bool__(self):
58 """
59 Returns True if all of the required_keys are available.
60 """
61 return self.is_ready()
62
63 __nonzero__ = __bool__
64
65 def __repr__(self):
66 return super(RelationContext, self).__repr__()
67
68 def is_ready(self):
69 """
70 Returns True if all of the `required_keys` are available from any units.
71 """
72 ready = len(self.get(self.name, [])) > 0
73 if not ready:
74 hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
75 return ready
76
77 def _is_ready(self, unit_data):
78 """
79 Helper method that tests a set of relation data and returns True if
80 all of the `required_keys` are present.
81 """
82 return set(unit_data.keys()).issuperset(set(self.required_keys))
83
84 def get_data(self):
85 """
86 Retrieve the relation data for each unit involved in a relation and,
87 if complete, store it in a list under `self[self.name]`. This
88 is automatically called when the RelationContext is instantiated.
89
90 The units are sorted lexographically first by the service ID, then by
91 the unit ID. Thus, if an interface has two other services, 'db:1'
92 and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
93 and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
94 set of data, the relation data for the units will be stored in the
95 order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
96
97 If you only care about a single unit on the relation, you can just
98 access it as `{{ interface[0]['key'] }}`. However, if you can at all
99 support multiple units on a relation, you should iterate over the list,
100 like::
101
102 {% for unit in interface -%}
103 {{ unit['key'] }}{% if not loop.last %},{% endif %}
104 {%- endfor %}
105
106 Note that since all sets of relation data from all related services and
107 units are in a single list, if you need to know which service or unit a
108 set of data came from, you'll need to extend this class to preserve
109 that information.
110 """
111 if not hookenv.relation_ids(self.name):
112 return
113
114 ns = self.setdefault(self.name, [])
115 for rid in sorted(hookenv.relation_ids(self.name)):
116 for unit in sorted(hookenv.related_units(rid)):
117 reldata = hookenv.relation_get(rid=rid, unit=unit)
118 if self._is_ready(reldata):
119 ns.append(reldata)
120
121 def provide_data(self):
122 """
123 Return data to be relation_set for this interface.
124 """
125 return {}
126
127
128class MysqlRelation(RelationContext):
129 """
130 Relation context for the `mysql` interface.
131
132 :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
133 :param list additional_required_keys: Extend the list of :attr:`required_keys`
134 """
135 name = 'db'
136 interface = 'mysql'
137 required_keys = ['host', 'user', 'password', 'database']
138
139
140class HttpRelation(RelationContext):
141 """
142 Relation context for the `http` interface.
143
144 :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
145 :param list additional_required_keys: Extend the list of :attr:`required_keys`
146 """
147 name = 'website'
148 interface = 'http'
149 required_keys = ['host', 'port']
150
151 def provide_data(self):
152 return {
153 'host': hookenv.unit_get('private-address'),
154 'port': 80,
155 }
156
157
158class RequiredConfig(dict):
159 """
160 Data context that loads config options with one or more mandatory options.
161
162 Once the required options have been changed from their default values, all
163 config options will be available, namespaced under `config` to prevent
164 potential naming conflicts (for example, between a config option and a
165 relation property).
166
167 :param list *args: List of options that must be changed from their default values.
168 """
169
170 def __init__(self, *args):
171 self.required_options = args
172 self['config'] = hookenv.config()
173 with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
174 self.config = yaml.load(fp).get('options', {})
175
176 def __bool__(self):
177 for option in self.required_options:
178 if option not in self['config']:
179 return False
180 current_value = self['config'][option]
181 default_value = self.config[option].get('default')
182 if current_value == default_value:
183 return False
184 if current_value in (None, '') and default_value in (None, ''):
185 return False
186 return True
187
188 def __nonzero__(self):
189 return self.__bool__()
190
191
192class StoredContext(dict):
193 """
194 A data context that always returns the data that it was first created with.
195
196 This is useful to do a one-time generation of things like passwords, that
197 will thereafter use the same value that was originally generated, instead
198 of generating a new value each time it is run.
199 """
200 def __init__(self, file_name, config_data):
201 """
202 If the file exists, populate `self` with the data from the file.
203 Otherwise, populate with the given data and persist it to the file.
204 """
205 if os.path.exists(file_name):
206 self.update(self.read_context(file_name))
207 else:
208 self.store_context(file_name, config_data)
209 self.update(config_data)
210
211 def store_context(self, file_name, config_data):
212 if not os.path.isabs(file_name):
213 file_name = os.path.join(hookenv.charm_dir(), file_name)
214 with open(file_name, 'w') as file_stream:
215 os.fchmod(file_stream.fileno(), 0o600)
216 yaml.dump(config_data, file_stream)
217
218 def read_context(self, file_name):
219 if not os.path.isabs(file_name):
220 file_name = os.path.join(hookenv.charm_dir(), file_name)
221 with open(file_name, 'r') as file_stream:
222 data = yaml.load(file_stream)
223 if not data:
224 raise OSError("%s is empty" % file_name)
225 return data
226
227
228class TemplateCallback(ManagerCallback):
229 """
230 Callback class that will render a Jinja2 template, for use as a ready
231 action.
232
233 :param str source: The template source file, relative to
234 `$CHARM_DIR/templates`
235
236 :param str target: The target to write the rendered template to
237 :param str owner: The owner of the rendered file
238 :param str group: The group of the rendered file
239 :param int perms: The permissions of the rendered file
240 """
241 def __init__(self, source, target,
242 owner='root', group='root', perms=0o444):
243 self.source = source
244 self.target = target
245 self.owner = owner
246 self.group = group
247 self.perms = perms
248
249 def __call__(self, manager, service_name, event_name):
250 service = manager.get_service(service_name)
251 context = {}
252 for ctx in service.get('required_data', []):
253 context.update(ctx)
254 templating.render(self.source, self.target, context,
255 self.owner, self.group, self.perms)
256
257
258# Convenience aliases for templates
259render_template = template = TemplateCallback
2600
=== removed file 'hooks/charmhelpers/core/strutils.py'
--- hooks/charmhelpers/core/strutils.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/strutils.py 1970-01-01 00:00:00 +0000
@@ -1,42 +0,0 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# This file is part of charm-helpers.
7#
8# charm-helpers is free software: you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License version 3 as
10# published by the Free Software Foundation.
11#
12# charm-helpers is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19
20import six
21
22
23def bool_from_string(value):
24 """Interpret string value as boolean.
25
26 Returns True if value translates to True otherwise False.
27 """
28 if isinstance(value, six.string_types):
29 value = six.text_type(value)
30 else:
31 msg = "Unable to interpret non-string value '%s' as boolean" % (value)
32 raise ValueError(msg)
33
34 value = value.strip().lower()
35
36 if value in ['y', 'yes', 'true', 't']:
37 return True
38 elif value in ['n', 'no', 'false', 'f']:
39 return False
40
41 msg = "Unable to interpret string value '%s' as boolean" % (value)
42 raise ValueError(msg)
430
=== removed file 'hooks/charmhelpers/core/sysctl.py'
--- hooks/charmhelpers/core/sysctl.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/sysctl.py 1970-01-01 00:00:00 +0000
@@ -1,56 +0,0 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# This file is part of charm-helpers.
7#
8# charm-helpers is free software: you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License version 3 as
10# published by the Free Software Foundation.
11#
12# charm-helpers is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19
20import yaml
21
22from subprocess import check_call
23
24from charmhelpers.core.hookenv import (
25 log,
26 DEBUG,
27 ERROR,
28)
29
30__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
31
32
33def create(sysctl_dict, sysctl_file):
34 """Creates a sysctl.conf file from a YAML associative array
35
36 :param sysctl_dict: a YAML-formatted string of sysctl options eg "{ 'kernel.max_pid': 1337 }"
37 :type sysctl_dict: str
38 :param sysctl_file: path to the sysctl file to be saved
39 :type sysctl_file: str or unicode
40 :returns: None
41 """
42 try:
43 sysctl_dict_parsed = yaml.safe_load(sysctl_dict)
44 except yaml.YAMLError:
45 log("Error parsing YAML sysctl_dict: {}".format(sysctl_dict),
46 level=ERROR)
47 return
48
49 with open(sysctl_file, "w") as fd:
50 for key, value in sysctl_dict_parsed.items():
51 fd.write("{}={}\n".format(key, value))
52
53 log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed),
54 level=DEBUG)
55
56 check_call(["sysctl", "-p", sysctl_file])
570
=== removed file 'hooks/charmhelpers/core/templating.py'
--- hooks/charmhelpers/core/templating.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/templating.py 1970-01-01 00:00:00 +0000
@@ -1,68 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18
19from charmhelpers.core import host
20from charmhelpers.core import hookenv
21
22
23def render(source, target, context, owner='root', group='root',
24 perms=0o444, templates_dir=None, encoding='UTF-8'):
25 """
26 Render a template.
27
28 The `source` path, if not absolute, is relative to the `templates_dir`.
29
30 The `target` path should be absolute.
31
32 The context should be a dict containing the values to be replaced in the
33 template.
34
35 The `owner`, `group`, and `perms` options will be passed to `write_file`.
36
37 If omitted, `templates_dir` defaults to the `templates` folder in the charm.
38
39 Note: Using this requires python-jinja2; if it is not installed, calling
40 this will attempt to use charmhelpers.fetch.apt_install to install it.
41 """
42 try:
43 from jinja2 import FileSystemLoader, Environment, exceptions
44 except ImportError:
45 try:
46 from charmhelpers.fetch import apt_install
47 except ImportError:
48 hookenv.log('Could not import jinja2, and could not import '
49 'charmhelpers.fetch to install it',
50 level=hookenv.ERROR)
51 raise
52 apt_install('python-jinja2', fatal=True)
53 from jinja2 import FileSystemLoader, Environment, exceptions
54
55 if templates_dir is None:
56 templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
57 loader = Environment(loader=FileSystemLoader(templates_dir))
58 try:
59 source = source
60 template = loader.get_template(source)
61 except exceptions.TemplateNotFound as e:
62 hookenv.log('Could not load template %s from %s.' %
63 (source, templates_dir),
64 level=hookenv.ERROR)
65 raise e
66 content = template.render(context)
67 host.mkdir(os.path.dirname(target), owner, group, perms=0o755)
68 host.write_file(target, content.encode(encoding), owner, group, perms)
690
=== removed file 'hooks/charmhelpers/core/unitdata.py'
--- hooks/charmhelpers/core/unitdata.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/core/unitdata.py 1970-01-01 00:00:00 +0000
@@ -1,477 +0,0 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3#
4# Copyright 2014-2015 Canonical Limited.
5#
6# This file is part of charm-helpers.
7#
8# charm-helpers is free software: you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License version 3 as
10# published by the Free Software Foundation.
11#
12# charm-helpers is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19#
20#
21# Authors:
22# Kapil Thangavelu <kapil.foss@gmail.com>
23#
24"""
25Intro
26-----
27
28A simple way to store state in units. This provides a key value
29storage with support for versioned, transactional operation,
30and can calculate deltas from previous values to simplify unit logic
31when processing changes.
32
33
34Hook Integration
35----------------
36
37There are several extant frameworks for hook execution, including
38
39 - charmhelpers.core.hookenv.Hooks
40 - charmhelpers.core.services.ServiceManager
41
42The storage classes are framework agnostic, one simple integration is
43via the HookData contextmanager. It will record the current hook
44execution environment (including relation data, config data, etc.),
45setup a transaction and allow easy access to the changes from
46previously seen values. One consequence of the integration is the
47reservation of particular keys ('rels', 'unit', 'env', 'config',
48'charm_revisions') for their respective values.
49
50Here's a fully worked integration example using hookenv.Hooks::
51
52 from charmhelper.core import hookenv, unitdata
53
54 hook_data = unitdata.HookData()
55 db = unitdata.kv()
56 hooks = hookenv.Hooks()
57
58 @hooks.hook
59 def config_changed():
60 # Print all changes to configuration from previously seen
61 # values.
62 for changed, (prev, cur) in hook_data.conf.items():
63 print('config changed', changed,
64 'previous value', prev,
65 'current value', cur)
66
67 # Get some unit specific bookeeping
68 if not db.get('pkg_key'):
69 key = urllib.urlopen('https://example.com/pkg_key').read()
70 db.set('pkg_key', key)
71
72 # Directly access all charm config as a mapping.
73 conf = db.getrange('config', True)
74
75 # Directly access all relation data as a mapping
76 rels = db.getrange('rels', True)
77
78 if __name__ == '__main__':
79 with hook_data():
80 hook.execute()
81
82
83A more basic integration is via the hook_scope context manager which simply
84manages transaction scope (and records hook name, and timestamp)::
85
86 >>> from unitdata import kv
87 >>> db = kv()
88 >>> with db.hook_scope('install'):
89 ... # do work, in transactional scope.
90 ... db.set('x', 1)
91 >>> db.get('x')
92 1
93
94
95Usage
96-----
97
98Values are automatically json de/serialized to preserve basic typing
99and complex data struct capabilities (dicts, lists, ints, booleans, etc).
100
101Individual values can be manipulated via get/set::
102
103 >>> kv.set('y', True)
104 >>> kv.get('y')
105 True
106
107 # We can set complex values (dicts, lists) as a single key.
108 >>> kv.set('config', {'a': 1, 'b': True'})
109
110 # Also supports returning dictionaries as a record which
111 # provides attribute access.
112 >>> config = kv.get('config', record=True)
113 >>> config.b
114 True
115
116
117Groups of keys can be manipulated with update/getrange::
118
119 >>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
120 >>> kv.getrange('gui.', strip=True)
121 {'z': 1, 'y': 2}
122
123When updating values, its very helpful to understand which values
124have actually changed and how have they changed. The storage
125provides a delta method to provide for this::
126
127 >>> data = {'debug': True, 'option': 2}
128 >>> delta = kv.delta(data, 'config.')
129 >>> delta.debug.previous
130 None
131 >>> delta.debug.current
132 True
133 >>> delta
134 {'debug': (None, True), 'option': (None, 2)}
135
136Note the delta method does not persist the actual change, it needs to
137be explicitly saved via 'update' method::
138
139 >>> kv.update(data, 'config.')
140
141Values modified in the context of a hook scope retain historical values
142associated to the hookname.
143
144 >>> with db.hook_scope('config-changed'):
145 ... db.set('x', 42)
146 >>> db.gethistory('x')
147 [(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
148 (2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
149
150"""
151
152import collections
153import contextlib
154import datetime
155import json
156import os
157import pprint
158import sqlite3
159import sys
160
161__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
162
163
164class Storage(object):
165 """Simple key value database for local unit state within charms.
166
167 Modifications are automatically committed at hook exit. That's
168 currently regardless of exit code.
169
170 To support dicts, lists, integer, floats, and booleans values
171 are automatically json encoded/decoded.
172 """
173 def __init__(self, path=None):
174 self.db_path = path
175 if path is None:
176 self.db_path = os.path.join(
177 os.environ.get('CHARM_DIR', ''), '.unit-state.db')
178 self.conn = sqlite3.connect('%s' % self.db_path)
179 self.cursor = self.conn.cursor()
180 self.revision = None
181 self._closed = False
182 self._init()
183
184 def close(self):
185 if self._closed:
186 return
187 self.flush(False)
188 self.cursor.close()
189 self.conn.close()
190 self._closed = True
191
192 def _scoped_query(self, stmt, params=None):
193 if params is None:
194 params = []
195 return stmt, params
196
197 def get(self, key, default=None, record=False):
198 self.cursor.execute(
199 *self._scoped_query(
200 'select data from kv where key=?', [key]))
201 result = self.cursor.fetchone()
202 if not result:
203 return default
204 if record:
205 return Record(json.loads(result[0]))
206 return json.loads(result[0])
207
208 def getrange(self, key_prefix, strip=False):
209 stmt = "select key, data from kv where key like '%s%%'" % key_prefix
210 self.cursor.execute(*self._scoped_query(stmt))
211 result = self.cursor.fetchall()
212
213 if not result:
214 return None
215 if not strip:
216 key_prefix = ''
217 return dict([
218 (k[len(key_prefix):], json.loads(v)) for k, v in result])
219
220 def update(self, mapping, prefix=""):
221 for k, v in mapping.items():
222 self.set("%s%s" % (prefix, k), v)
223
224 def unset(self, key):
225 self.cursor.execute('delete from kv where key=?', [key])
226 if self.revision and self.cursor.rowcount:
227 self.cursor.execute(
228 'insert into kv_revisions values (?, ?, ?)',
229 [key, self.revision, json.dumps('DELETED')])
230
231 def set(self, key, value):
232 serialized = json.dumps(value)
233
234 self.cursor.execute(
235 'select data from kv where key=?', [key])
236 exists = self.cursor.fetchone()
237
238 # Skip mutations to the same value
239 if exists:
240 if exists[0] == serialized:
241 return value
242
243 if not exists:
244 self.cursor.execute(
245 'insert into kv (key, data) values (?, ?)',
246 (key, serialized))
247 else:
248 self.cursor.execute('''
249 update kv
250 set data = ?
251 where key = ?''', [serialized, key])
252
253 # Save
254 if not self.revision:
255 return value
256
257 self.cursor.execute(
258 'select 1 from kv_revisions where key=? and revision=?',
259 [key, self.revision])
260 exists = self.cursor.fetchone()
261
262 if not exists:
263 self.cursor.execute(
264 '''insert into kv_revisions (
265 revision, key, data) values (?, ?, ?)''',
266 (self.revision, key, serialized))
267 else:
268 self.cursor.execute(
269 '''
270 update kv_revisions
271 set data = ?
272 where key = ?
273 and revision = ?''',
274 [serialized, key, self.revision])
275
276 return value
277
278 def delta(self, mapping, prefix):
279 """
280 return a delta containing values that have changed.
281 """
282 previous = self.getrange(prefix, strip=True)
283 if not previous:
284 pk = set()
285 else:
286 pk = set(previous.keys())
287 ck = set(mapping.keys())
288 delta = DeltaSet()
289
290 # added
291 for k in ck.difference(pk):
292 delta[k] = Delta(None, mapping[k])
293
294 # removed
295 for k in pk.difference(ck):
296 delta[k] = Delta(previous[k], None)
297
298 # changed
299 for k in pk.intersection(ck):
300 c = mapping[k]
301 p = previous[k]
302 if c != p:
303 delta[k] = Delta(p, c)
304
305 return delta
306
307 @contextlib.contextmanager
308 def hook_scope(self, name=""):
309 """Scope all future interactions to the current hook execution
310 revision."""
311 assert not self.revision
312 self.cursor.execute(
313 'insert into hooks (hook, date) values (?, ?)',
314 (name or sys.argv[0],
315 datetime.datetime.utcnow().isoformat()))
316 self.revision = self.cursor.lastrowid
317 try:
318 yield self.revision
319 self.revision = None
320 except:
321 self.flush(False)
322 self.revision = None
323 raise
324 else:
325 self.flush()
326
327 def flush(self, save=True):
328 if save:
329 self.conn.commit()
330 elif self._closed:
331 return
332 else:
333 self.conn.rollback()
334
335 def _init(self):
336 self.cursor.execute('''
337 create table if not exists kv (
338 key text,
339 data text,
340 primary key (key)
341 )''')
342 self.cursor.execute('''
343 create table if not exists kv_revisions (
344 key text,
345 revision integer,
346 data text,
347 primary key (key, revision)
348 )''')
349 self.cursor.execute('''
350 create table if not exists hooks (
351 version integer primary key autoincrement,
352 hook text,
353 date text
354 )''')
355 self.conn.commit()
356
357 def gethistory(self, key, deserialize=False):
358 self.cursor.execute(
359 '''
360 select kv.revision, kv.key, kv.data, h.hook, h.date
361 from kv_revisions kv,
362 hooks h
363 where kv.key=?
364 and kv.revision = h.version
365 ''', [key])
366 if deserialize is False:
367 return self.cursor.fetchall()
368 return map(_parse_history, self.cursor.fetchall())
369
370 def debug(self, fh=sys.stderr):
371 self.cursor.execute('select * from kv')
372 pprint.pprint(self.cursor.fetchall(), stream=fh)
373 self.cursor.execute('select * from kv_revisions')
374 pprint.pprint(self.cursor.fetchall(), stream=fh)
375
376
377def _parse_history(d):
378 return (d[0], d[1], json.loads(d[2]), d[3],
379 datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
380
381
382class HookData(object):
383 """Simple integration for existing hook exec frameworks.
384
385 Records all unit information, and stores deltas for processing
386 by the hook.
387
388 Sample::
389
390 from charmhelper.core import hookenv, unitdata
391
392 changes = unitdata.HookData()
393 db = unitdata.kv()
394 hooks = hookenv.Hooks()
395
396 @hooks.hook
397 def config_changed():
398 # View all changes to configuration
399 for changed, (prev, cur) in changes.conf.items():
400 print('config changed', changed,
401 'previous value', prev,
402 'current value', cur)
403
404 # Get some unit specific bookeeping
405 if not db.get('pkg_key'):
406 key = urllib.urlopen('https://example.com/pkg_key').read()
407 db.set('pkg_key', key)
408
409 if __name__ == '__main__':
410 with changes():
411 hook.execute()
412
413 """
414 def __init__(self):
415 self.kv = kv()
416 self.conf = None
417 self.rels = None
418
419 @contextlib.contextmanager
420 def __call__(self):
421 from charmhelpers.core import hookenv
422 hook_name = hookenv.hook_name()
423
424 with self.kv.hook_scope(hook_name):
425 self._record_charm_version(hookenv.charm_dir())
426 delta_config, delta_relation = self._record_hook(hookenv)
427 yield self.kv, delta_config, delta_relation
428
429 def _record_charm_version(self, charm_dir):
430 # Record revisions.. charm revisions are meaningless
431 # to charm authors as they don't control the revision.
432 # so logic dependnent on revision is not particularly
433 # useful, however it is useful for debugging analysis.
434 charm_rev = open(
435 os.path.join(charm_dir, 'revision')).read().strip()
436 charm_rev = charm_rev or '0'
437 revs = self.kv.get('charm_revisions', [])
438 if charm_rev not in revs:
439 revs.append(charm_rev.strip() or '0')
440 self.kv.set('charm_revisions', revs)
441
442 def _record_hook(self, hookenv):
443 data = hookenv.execution_environment()
444 self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
445 self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
446 self.kv.set('env', data['env'])
447 self.kv.set('unit', data['unit'])
448 self.kv.set('relid', data.get('relid'))
449 return conf_delta, rels_delta
450
451
452class Record(dict):
453
454 __slots__ = ()
455
456 def __getattr__(self, k):
457 if k in self:
458 return self[k]
459 raise AttributeError(k)
460
461
462class DeltaSet(Record):
463
464 __slots__ = ()
465
466
467Delta = collections.namedtuple('Delta', ['previous', 'current'])
468
469
470_KV = None
471
472
473def kv():
474 global _KV
475 if _KV is None:
476 _KV = Storage()
477 return _KV
4780
=== removed directory 'hooks/charmhelpers/fetch'
=== removed file 'hooks/charmhelpers/fetch/__init__.py'
--- hooks/charmhelpers/fetch/__init__.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/fetch/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,439 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import importlib
18from tempfile import NamedTemporaryFile
19import time
20from yaml import safe_load
21from charmhelpers.core.host import (
22 lsb_release
23)
24import subprocess
25from charmhelpers.core.hookenv import (
26 config,
27 log,
28)
29import os
30
31import six
32if six.PY3:
33 from urllib.parse import urlparse, urlunparse
34else:
35 from urlparse import urlparse, urlunparse
36
37
38CLOUD_ARCHIVE = """# Ubuntu Cloud Archive
39deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main
40"""
41PROPOSED_POCKET = """# Proposed
42deb http://archive.ubuntu.com/ubuntu {}-proposed main universe multiverse restricted
43"""
44CLOUD_ARCHIVE_POCKETS = {
45 # Folsom
46 'folsom': 'precise-updates/folsom',
47 'precise-folsom': 'precise-updates/folsom',
48 'precise-folsom/updates': 'precise-updates/folsom',
49 'precise-updates/folsom': 'precise-updates/folsom',
50 'folsom/proposed': 'precise-proposed/folsom',
51 'precise-folsom/proposed': 'precise-proposed/folsom',
52 'precise-proposed/folsom': 'precise-proposed/folsom',
53 # Grizzly
54 'grizzly': 'precise-updates/grizzly',
55 'precise-grizzly': 'precise-updates/grizzly',
56 'precise-grizzly/updates': 'precise-updates/grizzly',
57 'precise-updates/grizzly': 'precise-updates/grizzly',
58 'grizzly/proposed': 'precise-proposed/grizzly',
59 'precise-grizzly/proposed': 'precise-proposed/grizzly',
60 'precise-proposed/grizzly': 'precise-proposed/grizzly',
61 # Havana
62 'havana': 'precise-updates/havana',
63 'precise-havana': 'precise-updates/havana',
64 'precise-havana/updates': 'precise-updates/havana',
65 'precise-updates/havana': 'precise-updates/havana',
66 'havana/proposed': 'precise-proposed/havana',
67 'precise-havana/proposed': 'precise-proposed/havana',
68 'precise-proposed/havana': 'precise-proposed/havana',
69 # Icehouse
70 'icehouse': 'precise-updates/icehouse',
71 'precise-icehouse': 'precise-updates/icehouse',
72 'precise-icehouse/updates': 'precise-updates/icehouse',
73 'precise-updates/icehouse': 'precise-updates/icehouse',
74 'icehouse/proposed': 'precise-proposed/icehouse',
75 'precise-icehouse/proposed': 'precise-proposed/icehouse',
76 'precise-proposed/icehouse': 'precise-proposed/icehouse',
77 # Juno
78 'juno': 'trusty-updates/juno',
79 'trusty-juno': 'trusty-updates/juno',
80 'trusty-juno/updates': 'trusty-updates/juno',
81 'trusty-updates/juno': 'trusty-updates/juno',
82 'juno/proposed': 'trusty-proposed/juno',
83 'trusty-juno/proposed': 'trusty-proposed/juno',
84 'trusty-proposed/juno': 'trusty-proposed/juno',
85 # Kilo
86 'kilo': 'trusty-updates/kilo',
87 'trusty-kilo': 'trusty-updates/kilo',
88 'trusty-kilo/updates': 'trusty-updates/kilo',
89 'trusty-updates/kilo': 'trusty-updates/kilo',
90 'kilo/proposed': 'trusty-proposed/kilo',
91 'trusty-kilo/proposed': 'trusty-proposed/kilo',
92 'trusty-proposed/kilo': 'trusty-proposed/kilo',
93}
94
95# The order of this list is very important. Handlers should be listed in from
96# least- to most-specific URL matching.
97FETCH_HANDLERS = (
98 'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
99 'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
100 'charmhelpers.fetch.giturl.GitUrlFetchHandler',
101)
102
103APT_NO_LOCK = 100 # The return code for "couldn't acquire lock" in APT.
104APT_NO_LOCK_RETRY_DELAY = 10 # Wait 10 seconds between apt lock checks.
105APT_NO_LOCK_RETRY_COUNT = 30 # Retry to acquire the lock X times.
106
107
108class SourceConfigError(Exception):
109 pass
110
111
112class UnhandledSource(Exception):
113 pass
114
115
116class AptLockError(Exception):
117 pass
118
119
120class BaseFetchHandler(object):
121
122 """Base class for FetchHandler implementations in fetch plugins"""
123
124 def can_handle(self, source):
125 """Returns True if the source can be handled. Otherwise returns
126 a string explaining why it cannot"""
127 return "Wrong source type"
128
129 def install(self, source):
130 """Try to download and unpack the source. Return the path to the
131 unpacked files or raise UnhandledSource."""
132 raise UnhandledSource("Wrong source type {}".format(source))
133
134 def parse_url(self, url):
135 return urlparse(url)
136
137 def base_url(self, url):
138 """Return url without querystring or fragment"""
139 parts = list(self.parse_url(url))
140 parts[4:] = ['' for i in parts[4:]]
141 return urlunparse(parts)
142
143
144def filter_installed_packages(packages):
145 """Returns a list of packages that require installation"""
146 cache = apt_cache()
147 _pkgs = []
148 for package in packages:
149 try:
150 p = cache[package]
151 p.current_ver or _pkgs.append(package)
152 except KeyError:
153 log('Package {} has no installation candidate.'.format(package),
154 level='WARNING')
155 _pkgs.append(package)
156 return _pkgs
157
158
159def apt_cache(in_memory=True):
160 """Build and return an apt cache"""
161 import apt_pkg
162 apt_pkg.init()
163 if in_memory:
164 apt_pkg.config.set("Dir::Cache::pkgcache", "")
165 apt_pkg.config.set("Dir::Cache::srcpkgcache", "")
166 return apt_pkg.Cache()
167
168
169def apt_install(packages, options=None, fatal=False):
170 """Install one or more packages"""
171 if options is None:
172 options = ['--option=Dpkg::Options::=--force-confold']
173
174 cmd = ['apt-get', '--assume-yes']
175 cmd.extend(options)
176 cmd.append('install')
177 if isinstance(packages, six.string_types):
178 cmd.append(packages)
179 else:
180 cmd.extend(packages)
181 log("Installing {} with options: {}".format(packages,
182 options))
183 _run_apt_command(cmd, fatal)
184
185
186def apt_upgrade(options=None, fatal=False, dist=False):
187 """Upgrade all packages"""
188 if options is None:
189 options = ['--option=Dpkg::Options::=--force-confold']
190
191 cmd = ['apt-get', '--assume-yes']
192 cmd.extend(options)
193 if dist:
194 cmd.append('dist-upgrade')
195 else:
196 cmd.append('upgrade')
197 log("Upgrading with options: {}".format(options))
198 _run_apt_command(cmd, fatal)
199
200
201def apt_update(fatal=False):
202 """Update local apt cache"""
203 cmd = ['apt-get', 'update']
204 _run_apt_command(cmd, fatal)
205
206
207def apt_purge(packages, fatal=False):
208 """Purge one or more packages"""
209 cmd = ['apt-get', '--assume-yes', 'purge']
210 if isinstance(packages, six.string_types):
211 cmd.append(packages)
212 else:
213 cmd.extend(packages)
214 log("Purging {}".format(packages))
215 _run_apt_command(cmd, fatal)
216
217
218def apt_hold(packages, fatal=False):
219 """Hold one or more packages"""
220 cmd = ['apt-mark', 'hold']
221 if isinstance(packages, six.string_types):
222 cmd.append(packages)
223 else:
224 cmd.extend(packages)
225 log("Holding {}".format(packages))
226
227 if fatal:
228 subprocess.check_call(cmd)
229 else:
230 subprocess.call(cmd)
231
232
233def add_source(source, key=None):
234 """Add a package source to this system.
235
236 @param source: a URL or sources.list entry, as supported by
237 add-apt-repository(1). Examples::
238
239 ppa:charmers/example
240 deb https://stub:key@private.example.com/ubuntu trusty main
241
242 In addition:
243 'proposed:' may be used to enable the standard 'proposed'
244 pocket for the release.
245 'cloud:' may be used to activate official cloud archive pockets,
246 such as 'cloud:icehouse'
247 'distro' may be used as a noop
248
249 @param key: A key to be added to the system's APT keyring and used
250 to verify the signatures on packages. Ideally, this should be an
251 ASCII format GPG public key including the block headers. A GPG key
252 id may also be used, but be aware that only insecure protocols are
253 available to retrieve the actual public key from a public keyserver
254 placing your Juju environment at risk. ppa and cloud archive keys
255 are securely added automtically, so sould not be provided.
256 """
257 if source is None:
258 log('Source is not present. Skipping')
259 return
260
261 if (source.startswith('ppa:') or
262 source.startswith('http') or
263 source.startswith('deb ') or
264 source.startswith('cloud-archive:')):
265 subprocess.check_call(['add-apt-repository', '--yes', source])
266 elif source.startswith('cloud:'):
267 apt_install(filter_installed_packages(['ubuntu-cloud-keyring']),
268 fatal=True)
269 pocket = source.split(':')[-1]
270 if pocket not in CLOUD_ARCHIVE_POCKETS:
271 raise SourceConfigError(
272 'Unsupported cloud: source option %s' %
273 pocket)
274 actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket]
275 with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt:
276 apt.write(CLOUD_ARCHIVE.format(actual_pocket))
277 elif source == 'proposed':
278 release = lsb_release()['DISTRIB_CODENAME']
279 with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt:
280 apt.write(PROPOSED_POCKET.format(release))
281 elif source == 'distro':
282 pass
283 else:
284 log("Unknown source: {!r}".format(source))
285
286 if key:
287 if '-----BEGIN PGP PUBLIC KEY BLOCK-----' in key:
288 with NamedTemporaryFile('w+') as key_file:
289 key_file.write(key)
290 key_file.flush()
291 key_file.seek(0)
292 subprocess.check_call(['apt-key', 'add', '-'], stdin=key_file)
293 else:
294 # Note that hkp: is in no way a secure protocol. Using a
295 # GPG key id is pointless from a security POV unless you
296 # absolutely trust your network and DNS.
297 subprocess.check_call(['apt-key', 'adv', '--keyserver',
298 'hkp://keyserver.ubuntu.com:80', '--recv',
299 key])
300
301
302def configure_sources(update=False,
303 sources_var='install_sources',
304 keys_var='install_keys'):
305 """
306 Configure multiple sources from charm configuration.
307
308 The lists are encoded as yaml fragments in the configuration.
309 The frament needs to be included as a string. Sources and their
310 corresponding keys are of the types supported by add_source().
311
312 Example config:
313 install_sources: |
314 - "ppa:foo"
315 - "http://example.com/repo precise main"
316 install_keys: |
317 - null
318 - "a1b2c3d4"
319
320 Note that 'null' (a.k.a. None) should not be quoted.
321 """
322 sources = safe_load((config(sources_var) or '').strip()) or []
323 keys = safe_load((config(keys_var) or '').strip()) or None
324
325 if isinstance(sources, six.string_types):
326 sources = [sources]
327
328 if keys is None:
329 for source in sources:
330 add_source(source, None)
331 else:
332 if isinstance(keys, six.string_types):
333 keys = [keys]
334
335 if len(sources) != len(keys):
336 raise SourceConfigError(
337 'Install sources and keys lists are different lengths')
338 for source, key in zip(sources, keys):
339 add_source(source, key)
340 if update:
341 apt_update(fatal=True)
342
343
344def install_remote(source, *args, **kwargs):
345 """
346 Install a file tree from a remote source
347
348 The specified source should be a url of the form:
349 scheme://[host]/path[#[option=value][&...]]
350
351 Schemes supported are based on this modules submodules.
352 Options supported are submodule-specific.
353 Additional arguments are passed through to the submodule.
354
355 For example::
356
357 dest = install_remote('http://example.com/archive.tgz',
358 checksum='deadbeef',
359 hash_type='sha1')
360
361 This will download `archive.tgz`, validate it using SHA1 and, if
362 the file is ok, extract it and return the directory in which it
363 was extracted. If the checksum fails, it will raise
364 :class:`charmhelpers.core.host.ChecksumError`.
365 """
366 # We ONLY check for True here because can_handle may return a string
367 # explaining why it can't handle a given source.
368 handlers = [h for h in plugins() if h.can_handle(source) is True]
369 installed_to = None
370 for handler in handlers:
371 try:
372 installed_to = handler.install(source, *args, **kwargs)
373 except UnhandledSource:
374 pass
375 if not installed_to:
376 raise UnhandledSource("No handler found for source {}".format(source))
377 return installed_to
378
379
380def install_from_config(config_var_name):
381 charm_config = config()
382 source = charm_config[config_var_name]
383 return install_remote(source)
384
385
386def plugins(fetch_handlers=None):
387 if not fetch_handlers:
388 fetch_handlers = FETCH_HANDLERS
389 plugin_list = []
390 for handler_name in fetch_handlers:
391 package, classname = handler_name.rsplit('.', 1)
392 try:
393 handler_class = getattr(
394 importlib.import_module(package),
395 classname)
396 plugin_list.append(handler_class())
397 except (ImportError, AttributeError):
398 # Skip missing plugins so that they can be ommitted from
399 # installation if desired
400 log("FetchHandler {} not found, skipping plugin".format(
401 handler_name))
402 return plugin_list
403
404
405def _run_apt_command(cmd, fatal=False):
406 """
407 Run an APT command, checking output and retrying if the fatal flag is set
408 to True.
409
410 :param: cmd: str: The apt command to run.
411 :param: fatal: bool: Whether the command's output should be checked and
412 retried.
413 """
414 env = os.environ.copy()
415
416 if 'DEBIAN_FRONTEND' not in env:
417 env['DEBIAN_FRONTEND'] = 'noninteractive'
418
419 if fatal:
420 retry_count = 0
421 result = None
422
423 # If the command is considered "fatal", we need to retry if the apt
424 # lock was not acquired.
425
426 while result is None or result == APT_NO_LOCK:
427 try:
428 result = subprocess.check_call(cmd, env=env)
429 except subprocess.CalledProcessError as e:
430 retry_count = retry_count + 1
431 if retry_count > APT_NO_LOCK_RETRY_COUNT:
432 raise
433 result = e.returncode
434 log("Couldn't acquire DPKG lock. Will retry in {} seconds."
435 "".format(APT_NO_LOCK_RETRY_DELAY))
436 time.sleep(APT_NO_LOCK_RETRY_DELAY)
437
438 else:
439 subprocess.call(cmd, env=env)
4400
=== removed file 'hooks/charmhelpers/fetch/archiveurl.py'
--- hooks/charmhelpers/fetch/archiveurl.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/fetch/archiveurl.py 1970-01-01 00:00:00 +0000
@@ -1,161 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18import hashlib
19import re
20
21from charmhelpers.fetch import (
22 BaseFetchHandler,
23 UnhandledSource
24)
25from charmhelpers.payload.archive import (
26 get_archive_handler,
27 extract,
28)
29from charmhelpers.core.host import mkdir, check_hash
30
31import six
32if six.PY3:
33 from urllib.request import (
34 build_opener, install_opener, urlopen, urlretrieve,
35 HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler,
36 )
37 from urllib.parse import urlparse, urlunparse, parse_qs
38 from urllib.error import URLError
39else:
40 from urllib import urlretrieve
41 from urllib2 import (
42 build_opener, install_opener, urlopen,
43 HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler,
44 URLError
45 )
46 from urlparse import urlparse, urlunparse, parse_qs
47
48
49def splituser(host):
50 '''urllib.splituser(), but six's support of this seems broken'''
51 _userprog = re.compile('^(.*)@(.*)$')
52 match = _userprog.match(host)
53 if match:
54 return match.group(1, 2)
55 return None, host
56
57
58def splitpasswd(user):
59 '''urllib.splitpasswd(), but six's support of this is missing'''
60 _passwdprog = re.compile('^([^:]*):(.*)$', re.S)
61 match = _passwdprog.match(user)
62 if match:
63 return match.group(1, 2)
64 return user, None
65
66
67class ArchiveUrlFetchHandler(BaseFetchHandler):
68 """
69 Handler to download archive files from arbitrary URLs.
70
71 Can fetch from http, https, ftp, and file URLs.
72
73 Can install either tarballs (.tar, .tgz, .tbz2, etc) or zip files.
74
75 Installs the contents of the archive in $CHARM_DIR/fetched/.
76 """
77 def can_handle(self, source):
78 url_parts = self.parse_url(source)
79 if url_parts.scheme not in ('http', 'https', 'ftp', 'file'):
80 return "Wrong source type"
81 if get_archive_handler(self.base_url(source)):
82 return True
83 return False
84
85 def download(self, source, dest):
86 """
87 Download an archive file.
88
89 :param str source: URL pointing to an archive file.
90 :param str dest: Local path location to download archive file to.
91 """
92 # propogate all exceptions
93 # URLError, OSError, etc
94 proto, netloc, path, params, query, fragment = urlparse(source)
95 if proto in ('http', 'https'):
96 auth, barehost = splituser(netloc)
97 if auth is not None:
98 source = urlunparse((proto, barehost, path, params, query, fragment))
99 username, password = splitpasswd(auth)
100 passman = HTTPPasswordMgrWithDefaultRealm()
101 # Realm is set to None in add_password to force the username and password
102 # to be used whatever the realm
103 passman.add_password(None, source, username, password)
104 authhandler = HTTPBasicAuthHandler(passman)
105 opener = build_opener(authhandler)
106 install_opener(opener)
107 response = urlopen(source)
108 try:
109 with open(dest, 'w') as dest_file:
110 dest_file.write(response.read())
111 except Exception as e:
112 if os.path.isfile(dest):
113 os.unlink(dest)
114 raise e
115
116 # Mandatory file validation via Sha1 or MD5 hashing.
117 def download_and_validate(self, url, hashsum, validate="sha1"):
118 tempfile, headers = urlretrieve(url)
119 check_hash(tempfile, hashsum, validate)
120 return tempfile
121
122 def install(self, source, dest=None, checksum=None, hash_type='sha1'):
123 """
124 Download and install an archive file, with optional checksum validation.
125
126 The checksum can also be given on the `source` URL's fragment.
127 For example::
128
129 handler.install('http://example.com/file.tgz#sha1=deadbeef')
130
131 :param str source: URL pointing to an archive file.
132 :param str dest: Local destination path to install to. If not given,
133 installs to `$CHARM_DIR/archives/archive_file_name`.
134 :param str checksum: If given, validate the archive file after download.
135 :param str hash_type: Algorithm used to generate `checksum`.
136 Can be any hash alrgorithm supported by :mod:`hashlib`,
137 such as md5, sha1, sha256, sha512, etc.
138
139 """
140 url_parts = self.parse_url(source)
141 dest_dir = os.path.join(os.environ.get('CHARM_DIR'), 'fetched')
142 if not os.path.exists(dest_dir):
143 mkdir(dest_dir, perms=0o755)
144 dld_file = os.path.join(dest_dir, os.path.basename(url_parts.path))
145 try:
146 self.download(source, dld_file)
147 except URLError as e:
148 raise UnhandledSource(e.reason)
149 except OSError as e:
150 raise UnhandledSource(e.strerror)
151 options = parse_qs(url_parts.fragment)
152 for key, value in options.items():
153 if not six.PY3:
154 algorithms = hashlib.algorithms
155 else:
156 algorithms = hashlib.algorithms_available
157 if key in algorithms:
158 check_hash(dld_file, value, key)
159 if checksum:
160 check_hash(dld_file, checksum, hash_type)
161 return extract(dld_file, dest)
1620
=== removed file 'hooks/charmhelpers/fetch/bzrurl.py'
--- hooks/charmhelpers/fetch/bzrurl.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/fetch/bzrurl.py 1970-01-01 00:00:00 +0000
@@ -1,78 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18from charmhelpers.fetch import (
19 BaseFetchHandler,
20 UnhandledSource
21)
22from charmhelpers.core.host import mkdir
23
24import six
25if six.PY3:
26 raise ImportError('bzrlib does not support Python3')
27
28try:
29 from bzrlib.branch import Branch
30 from bzrlib import bzrdir, workingtree, errors
31except ImportError:
32 from charmhelpers.fetch import apt_install
33 apt_install("python-bzrlib")
34 from bzrlib.branch import Branch
35 from bzrlib import bzrdir, workingtree, errors
36
37
38class BzrUrlFetchHandler(BaseFetchHandler):
39 """Handler for bazaar branches via generic and lp URLs"""
40 def can_handle(self, source):
41 url_parts = self.parse_url(source)
42 if url_parts.scheme not in ('bzr+ssh', 'lp'):
43 return False
44 else:
45 return True
46
47 def branch(self, source, dest):
48 url_parts = self.parse_url(source)
49 # If we use lp:branchname scheme we need to load plugins
50 if not self.can_handle(source):
51 raise UnhandledSource("Cannot handle {}".format(source))
52 if url_parts.scheme == "lp":
53 from bzrlib.plugin import load_plugins
54 load_plugins()
55 try:
56 local_branch = bzrdir.BzrDir.create_branch_convenience(dest)
57 except errors.AlreadyControlDirError:
58 local_branch = Branch.open(dest)
59 try:
60 remote_branch = Branch.open(source)
61 remote_branch.push(local_branch)
62 tree = workingtree.WorkingTree.open(dest)
63 tree.update()
64 except Exception as e:
65 raise e
66
67 def install(self, source):
68 url_parts = self.parse_url(source)
69 branch_name = url_parts.path.strip("/").split("/")[-1]
70 dest_dir = os.path.join(os.environ.get('CHARM_DIR'), "fetched",
71 branch_name)
72 if not os.path.exists(dest_dir):
73 mkdir(dest_dir, perms=0o755)
74 try:
75 self.branch(source, dest_dir)
76 except OSError as e:
77 raise UnhandledSource(e.strerror)
78 return dest_dir
790
=== removed file 'hooks/charmhelpers/fetch/giturl.py'
--- hooks/charmhelpers/fetch/giturl.py 2015-02-19 16:21:31 +0000
+++ hooks/charmhelpers/fetch/giturl.py 1970-01-01 00:00:00 +0000
@@ -1,71 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18from charmhelpers.fetch import (
19 BaseFetchHandler,
20 UnhandledSource
21)
22from charmhelpers.core.host import mkdir
23
24import six
25if six.PY3:
26 raise ImportError('GitPython does not support Python 3')
27
28try:
29 from git import Repo
30except ImportError:
31 from charmhelpers.fetch import apt_install
32 apt_install("python-git")
33 from git import Repo
34
35from git.exc import GitCommandError # noqa E402
36
37
38class GitUrlFetchHandler(BaseFetchHandler):
39 """Handler for git branches via generic and github URLs"""
40 def can_handle(self, source):
41 url_parts = self.parse_url(source)
42 # TODO (mattyw) no support for ssh git@ yet
43 if url_parts.scheme not in ('http', 'https', 'git'):
44 return False
45 else:
46 return True
47
48 def clone(self, source, dest, branch):
49 if not self.can_handle(source):
50 raise UnhandledSource("Cannot handle {}".format(source))
51
52 repo = Repo.clone_from(source, dest)
53 repo.git.checkout(branch)
54
55 def install(self, source, branch="master", dest=None):
56 url_parts = self.parse_url(source)
57 branch_name = url_parts.path.strip("/").split("/")[-1]
58 if dest:
59 dest_dir = os.path.join(dest, branch_name)
60 else:
61 dest_dir = os.path.join(os.environ.get('CHARM_DIR'), "fetched",
62 branch_name)
63 if not os.path.exists(dest_dir):
64 mkdir(dest_dir, perms=0o755)
65 try:
66 self.clone(source, dest_dir, branch)
67 except GitCommandError as e:
68 raise UnhandledSource(e.message)
69 except OSError as e:
70 raise UnhandledSource(e.strerror)
71 return dest_dir
720
=== removed file 'hooks/config-changed'
--- hooks/config-changed 2015-02-19 21:19:01 +0000
+++ hooks/config-changed 1970-01-01 00:00:00 +0000
@@ -1,43 +0,0 @@
1#!/usr/bin/python
2
3#
4# Copyright 2015 Canonical Ltd.
5#
6# Authors:
7# Liang Chen <liang.chen@ubuntu.com>
8#
9import subprocess
10import sys
11
12from charmhelpers.core.hookenv import (
13 config,
14 Hooks, UnregisteredHookError,
15 log, ERROR,
16)
17
18from utils import render_template
19
20hooks = Hooks()
21
22
23def emit_lxc_br_conf():
24 lxc_context = {}
25 if config('new-lxc-network'):
26 lxc_context['new_network'] = True
27
28 lxc_bridge_conf = "/etc/network/interfaces.d/lxcbr0.cfg"
29 with open(lxc_bridge_conf, 'w') as lxc_br_conf:
30 lxc_br_conf.write(render_template('lxc-bridge.conf', lxc_context))
31
32
33@hooks.hook('config-changed')
34def config_changed():
35 emit_lxc_br_conf()
36 cmd = ['ifup', 'lxcbr0']
37 subprocess.check_call(cmd)
38
39if __name__ == '__main__':
40 try:
41 hooks.execute(sys.argv)
42 except UnregisteredHookError as e:
43 log('Unknown hook {} - skipping.'.format(e))
440
=== removed file 'hooks/hooks.py'
--- hooks/hooks.py 2015-02-19 21:19:01 +0000
+++ hooks/hooks.py 1970-01-01 00:00:00 +0000
@@ -1,45 +0,0 @@
1#!/usr/bin/python
2
3#
4# Copyright 2015 Canonical Ltd.
5#
6# Authors:
7# Liang Chen <liang.chen@ubuntu.com>
8#
9
10import subprocess
11import sys
12
13from charmhelpers.core.hookenv import (
14 config,
15 Hooks, UnregisteredHookError,
16 log,
17)
18
19from utils import render_template
20
21hooks = Hooks()
22
23
24def emit_lxc_br_conf():
25 lxc_context = {}
26 if config('new-lxc-network'):
27 lxc_context['new_network'] = True
28
29 lxc_bridge_conf = "/etc/network/interfaces.d/lxcbr0.cfg"
30 with open(lxc_bridge_conf, 'w') as lxc_br_conf:
31 lxc_br_conf.write(render_template('lxc-bridge.conf', lxc_context))
32
33
34@hooks.hook('config-changed')
35def config_changed():
36 emit_lxc_br_conf()
37 cmd = ['ifup', 'lxcbr0']
38 subprocess.check_call(cmd)
39
40
41if __name__ == '__main__':
42 try:
43 hooks.execute(sys.argv)
44 except UnregisteredHookError as e:
45 log('Unknown hook {} - skipping.'.format(e))
460
=== removed file 'hooks/install'
--- hooks/install 2012-05-03 22:17:19 +0000
+++ hooks/install 1970-01-01 00:00:00 +0000
@@ -1,2 +0,0 @@
1#!/bin/bash
2# Does nothing. - charm proof requires an install hook.
30
=== removed file 'hooks/utils.py'
--- hooks/utils.py 2015-02-19 21:19:01 +0000
+++ hooks/utils.py 1970-01-01 00:00:00 +0000
@@ -1,27 +0,0 @@
1#
2# Copyright 2015 Canonical Ltd.
3#
4# Authors:
5# Liang Chen <liang.chen@ubuntu.com>
6#
7
8TEMPLATES_DIR = 'templates'
9
10from charmhelpers.fetch import (
11 apt_install,
12 filter_installed_packages
13)
14
15try:
16 import jinja2
17except ImportError:
18 apt_install(filter_installed_packages(['python-jinja2']),
19 fatal=True)
20 import jinja2
21
22
23def render_template(template_name, context, template_dir=TEMPLATES_DIR):
24 templates = jinja2.Environment(
25 loader=jinja2.FileSystemLoader(template_dir))
26 template = templates.get_template(template_name)
27 return template.render(context)
280
=== modified file 'metadata.yaml'
--- metadata.yaml 2013-12-11 21:28:41 +0000
+++ metadata.yaml 2015-04-30 13:14:23 +0000
@@ -3,5 +3,5 @@
3maintainer: Charmers <juju@lists.ubuntu.com>3maintainer: Charmers <juju@lists.ubuntu.com>
4description: |4description: |
5 This simply deploys Ubuntu Server.5 This simply deploys Ubuntu Server.
6categories:6tags:
7 - misc7 - misc
88
=== removed directory 'templates'
=== removed file 'templates/lxc-bridge.conf'
--- templates/lxc-bridge.conf 2015-02-19 21:19:01 +0000
+++ templates/lxc-bridge.conf 1970-01-01 00:00:00 +0000
@@ -1,10 +0,0 @@
1
2{% if new_network -%}
3auto eth0
4iface eth0 inet manual
5
6auto lxcbr0
7iface lxcbr0 inet dhcp
8 bridge_ports eth0
9
10{% endif -%}
110
=== modified file 'tests/00-setup.sh'
--- tests/00-setup.sh 2015-02-19 17:29:32 +0000
+++ tests/00-setup.sh 2015-04-30 13:14:23 +0000
@@ -1,15 +1,9 @@
1#!/bin/bash1#!/bin/bash -x
22
3# This script runs first to set up the environment for the tests.3# Install amulet if not already installed.
4
5set -x
6
7# Check if amulet is installed before adding repository and updating apt-get.
8dpkg -s amulet4dpkg -s amulet
9if [ $? -ne 0 ]; then5if [ $? -ne 0 ]; then
10 sudo add-apt-repository -y ppa:juju/stable6 sudo add-apt-repository -y ppa:juju/stable
11 sudo apt-get update -qq 7 sudo apt-get update -qq
12 sudo apt-get install -y amulet8 sudo apt-get install -y amulet
13fi9fi
14# Install any additional packages needed for tests here.
15
1610
=== added file 'tests/010_basic_precise'
--- tests/010_basic_precise 1970-01-01 00:00:00 +0000
+++ tests/010_basic_precise 2015-04-30 13:14:23 +0000
@@ -0,0 +1,7 @@
1#!/usr/bin/python
2"""Amulet tests on a basic ubuntu charm deployment on precise."""
3
4from basic_deployment import ubuntu_basic_deployment
5
6if __name__ == '__main__':
7 ubuntu_basic_deployment(series='precise')
08
=== added file 'tests/015_basic_trusty'
--- tests/015_basic_trusty 1970-01-01 00:00:00 +0000
+++ tests/015_basic_trusty 2015-04-30 13:14:23 +0000
@@ -0,0 +1,7 @@
1#!/usr/bin/python
2"""Amulet tests on a basic ubuntu charm deployment on trusty."""
3
4from basic_deployment import ubuntu_basic_deployment
5
6if __name__ == '__main__':
7 ubuntu_basic_deployment(series='trusty')
08
=== added file 'tests/020_basic_utopic'
--- tests/020_basic_utopic 1970-01-01 00:00:00 +0000
+++ tests/020_basic_utopic 2015-04-30 13:14:23 +0000
@@ -0,0 +1,7 @@
1#!/usr/bin/python
2"""Amulet tests on a basic ubuntu charm deployment on utopic."""
3
4from basic_deployment import ubuntu_basic_deployment
5
6if __name__ == '__main__':
7 ubuntu_basic_deployment(series='utopic')
08
=== added file 'tests/025_basic_vivid'
--- tests/025_basic_vivid 1970-01-01 00:00:00 +0000
+++ tests/025_basic_vivid 2015-04-30 13:14:23 +0000
@@ -0,0 +1,7 @@
1#!/usr/bin/python
2"""Amulet tests on a basic ubuntu charm deployment on trusty."""
3
4from basic_deployment import ubuntu_basic_deployment
5
6if __name__ == '__main__':
7 ubuntu_basic_deployment(series='vivid')
08
=== removed file 'tests/10-deploy-test.py'
--- tests/10-deploy-test.py 2015-03-27 15:26:53 +0000
+++ tests/10-deploy-test.py 1970-01-01 00:00:00 +0000
@@ -1,90 +0,0 @@
1#!/usr/bin/python3
2
3# This Amulet based tests
4# The goal is to ensure the Ubuntu charm
5# sucessfully deploys and can be accessed.
6# Note the Ubuntu charm does not have any
7# relations or config options.
8
9import amulet
10
11# Timeout value, in seconds to deploy the environment
12seconds = 900
13series = 'trusty'
14
15# Set up the deployer module to interact and set up the environment.
16d = amulet.Deployment(series=series)
17
18# Define the environment in terms of charms, their config, and relations.
19
20# Add the Ubuntu charm to the deployment.
21d.add('ubuntu')
22
23# Deploy the environment currently defined
24try:
25 # Wait the defined about amount of time to deploy the environment.
26 # Setup makes sure the services are deployed, related, and in a
27 # "started" state.
28 d.setup(timeout=seconds)
29 # Use a sentry to ensure there are no remaining hooks being execute
30 # on any of the nodes
31except amulet.helpers.TimeoutError:
32 # Pending the configuration the test will fail or be skipped
33 # if not deployed properly.
34 error_message = 'The environment did not deploy in %d seconds.' % seconds
35 amulet.raise_status(amulet.SKIP, msg=error_message)
36except:
37 # Something else has gone wrong, raise the error so we can see it and this
38 # will automatically "FAIL" the test.
39 raise
40
41# Access the Ubuntu instance to ensure it has been deployed correctly
42
43# Define the commands to be ran
44lsb_command = 'lsb_release -cs'
45uname_command = 'uname -a'
46lxc_interface_command = 'ifconfig -s lxcbr0'
47
48print(lsb_command)
49# Print the release information
50output, code = d.sentry.unit['ubuntu/0'].run(lsb_command)
51print(output)
52# Confirm the lsb_release command ran successfully
53if (code != 0):
54 message = 'The ' + lsb_command + ' did not return the expected return code of 0.'
55 amulet.raise_status(amulet.FAIL, msg=message)
56else:
57 if series in output:
58 print('The series is correct.')
59 else:
60 message = 'The series is not correct.'
61 print(message)
62 amulet.raise_status(amulet.FAIL, msg=message)
63
64print(uname_command)
65
66# Get the uname -a output
67output, code = d.sentry.unit['ubuntu/0'].run(uname_command)
68print(output)
69# Confirm the uname command was ran successfully
70if (code != 0):
71 message = 'The ' + uname_command + ' did not return the expected return code of 0.'
72 amulet.raise_status(amulet.FAIL, msg=message)
73else:
74 message = 'The ' + uname_command + ' successfully executed.'
75 print(output)
76 print(message)
77
78# Test creating a new network for LXC via new-lxc-network config option.
79# Set the new-lxc-network config option to create a new network for LXC.
80d.configure('ubuntu', {'new-lxc-network': 'true'})
81# Confirm the the lxc bridge interface was sucessfully created
82output, code = d.sentry.unit['ubuntu/0'].run(lxc_interface_command)
83if (code != 0):
84 error_message = 'The lxcbr0 interface was not created.'
85 print(output)
86 amulet.raise_status(amulet.FAIL, msg=error_message)
87else:
88 message = 'The lxcbr0 interface sucessfully created.'
89 print(output)
90 print(message)
910
=== added file 'tests/basic_deployment.py'
--- tests/basic_deployment.py 1970-01-01 00:00:00 +0000
+++ tests/basic_deployment.py 2015-04-30 13:14:23 +0000
@@ -0,0 +1,49 @@
1#!/usr/bin/python3
2"""
3Ubuntu charm functional test. Take note that the Ubuntu
4charm does not have any relations or config options
5to exercise.
6"""
7
8import amulet
9from charmhelpers.contrib.amulet.utils import AmuletUtils
10import logging
11
12
13def ubuntu_basic_deployment(series):
14 """ Common test routines to run per-series. """
15
16 # Initialize
17 seconds = 900
18 u = AmuletUtils(logging.DEBUG)
19 d = amulet.Deployment(series=series)
20 d.add('ubuntu')
21 unit = 'ubuntu/0'
22 lsb_command = 'lsb_release -cs'
23
24 # Deploy services, wait for started state. Fail or skip on timeout.
25 try:
26 d.setup(timeout=seconds)
27 except amulet.helpers.TimeoutError:
28 message = 'Deployment timed out ({}s)'.format(seconds)
29 amulet.raise_status(amulet.SKIP, msg=message)
30 except:
31 raise
32
33 # Confirm Ubuntu release name from the unit.
34 u.log.debug('Command: {}'.format(lsb_command))
35 output, code = d.sentry.unit[unit].run(lsb_command)
36 u.log.debug('Output: {}'.format(output))
37
38 if (code != 0):
39 message = 'Command FAIL: {}'.format(lsb_command)
40 u.log.error(message)
41 amulet.raise_status(amulet.FAIL, msg=message)
42 else:
43 if series in output:
44 message = 'Series: OK'
45 u.log.info(message)
46 else:
47 message = 'Series: FAIL ({})'.format(output)
48 u.log.error(message)
49 amulet.raise_status(amulet.FAIL, msg=message)
050
=== added directory 'tests/charmhelpers'
=== added file 'tests/charmhelpers/__init__.py'
--- tests/charmhelpers/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/__init__.py 2015-04-30 13:14:23 +0000
@@ -0,0 +1,38 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17# Bootstrap charm-helpers, installing its dependencies if necessary using
18# only standard libraries.
19import subprocess
20import sys
21
22try:
23 import six # flake8: noqa
24except ImportError:
25 if sys.version_info.major == 2:
26 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
27 else:
28 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
29 import six # flake8: noqa
30
31try:
32 import yaml # flake8: noqa
33except ImportError:
34 if sys.version_info.major == 2:
35 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
36 else:
37 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
38 import yaml # flake8: noqa
039
=== added directory 'tests/charmhelpers/contrib'
=== added file 'tests/charmhelpers/contrib/__init__.py'
--- tests/charmhelpers/contrib/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/__init__.py 2015-04-30 13:14:23 +0000
@@ -0,0 +1,15 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
016
=== added directory 'tests/charmhelpers/contrib/amulet'
=== added file 'tests/charmhelpers/contrib/amulet/__init__.py'
--- tests/charmhelpers/contrib/amulet/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/amulet/__init__.py 2015-04-30 13:14:23 +0000
@@ -0,0 +1,15 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
016
=== added file 'tests/charmhelpers/contrib/amulet/deployment.py'
--- tests/charmhelpers/contrib/amulet/deployment.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/amulet/deployment.py 2015-04-30 13:14:23 +0000
@@ -0,0 +1,93 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import amulet
18import os
19import six
20
21
22class AmuletDeployment(object):
23 """Amulet deployment.
24
25 This class provides generic Amulet deployment and test runner
26 methods.
27 """
28
29 def __init__(self, series=None):
30 """Initialize the deployment environment."""
31 self.series = None
32
33 if series:
34 self.series = series
35 self.d = amulet.Deployment(series=self.series)
36 else:
37 self.d = amulet.Deployment()
38
39 def _add_services(self, this_service, other_services):
40 """Add services.
41
42 Add services to the deployment where this_service is the local charm
43 that we're testing and other_services are the other services that
44 are being used in the local amulet tests.
45 """
46 if this_service['name'] != os.path.basename(os.getcwd()):
47 s = this_service['name']
48 msg = "The charm's root directory name needs to be {}".format(s)
49 amulet.raise_status(amulet.FAIL, msg=msg)
50
51 if 'units' not in this_service:
52 this_service['units'] = 1
53
54 self.d.add(this_service['name'], units=this_service['units'])
55
56 for svc in other_services:
57 if 'location' in svc:
58 branch_location = svc['location']
59 elif self.series:
60 branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
61 else:
62 branch_location = None
63
64 if 'units' not in svc:
65 svc['units'] = 1
66
67 self.d.add(svc['name'], charm=branch_location, units=svc['units'])
68
69 def _add_relations(self, relations):
70 """Add all of the relations for the services."""
71 for k, v in six.iteritems(relations):
72 self.d.relate(k, v)
73
74 def _configure_services(self, configs):
75 """Configure all of the services."""
76 for service, config in six.iteritems(configs):
77 self.d.configure(service, config)
78
79 def _deploy(self):
80 """Deploy environment and wait for all hooks to finish executing."""
81 try:
82 self.d.setup(timeout=900)
83 self.d.sentry.wait(timeout=900)
84 except amulet.helpers.TimeoutError:
85 amulet.raise_status(amulet.FAIL, msg="Deployment timed out")
86 except Exception:
87 raise
88
89 def run_tests(self):
90 """Run all of the methods that are prefixed with 'test_'."""
91 for test in dir(self):
92 if test.startswith('test_'):
93 getattr(self, test)()
094
=== added file 'tests/charmhelpers/contrib/amulet/utils.py'
--- tests/charmhelpers/contrib/amulet/utils.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/amulet/utils.py 2015-04-30 13:14:23 +0000
@@ -0,0 +1,323 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import ConfigParser
18import io
19import logging
20import re
21import sys
22import time
23
24import six
25
26
27class AmuletUtils(object):
28 """Amulet utilities.
29
30 This class provides common utility functions that are used by Amulet
31 tests.
32 """
33
34 def __init__(self, log_level=logging.ERROR):
35 self.log = self.get_logger(level=log_level)
36
37 def get_logger(self, name="amulet-logger", level=logging.DEBUG):
38 """Get a logger object that will log to stdout."""
39 log = logging
40 logger = log.getLogger(name)
41 fmt = log.Formatter("%(asctime)s %(funcName)s "
42 "%(levelname)s: %(message)s")
43
44 handler = log.StreamHandler(stream=sys.stdout)
45 handler.setLevel(level)
46 handler.setFormatter(fmt)
47
48 logger.addHandler(handler)
49 logger.setLevel(level)
50
51 return logger
52
53 def valid_ip(self, ip):
54 if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
55 return True
56 else:
57 return False
58
59 def valid_url(self, url):
60 p = re.compile(
61 r'^(?:http|ftp)s?://'
62 r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
63 r'localhost|'
64 r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
65 r'(?::\d+)?'
66 r'(?:/?|[/?]\S+)$',
67 re.IGNORECASE)
68 if p.match(url):
69 return True
70 else:
71 return False
72
73 def validate_services(self, commands):
74 """Validate services.
75
76 Verify the specified services are running on the corresponding
77 service units.
78 """
79 for k, v in six.iteritems(commands):
80 for cmd in v:
81 output, code = k.run(cmd)
82 self.log.debug('{} `{}` returned '
83 '{}'.format(k.info['unit_name'],
84 cmd, code))
85 if code != 0:
86 return "command `{}` returned {}".format(cmd, str(code))
87 return None
88
89 def _get_config(self, unit, filename):
90 """Get a ConfigParser object for parsing a unit's config file."""
91 file_contents = unit.file_contents(filename)
92
93 # NOTE(beisner): by default, ConfigParser does not handle options
94 # with no value, such as the flags used in the mysql my.cnf file.
95 # https://bugs.python.org/issue7005
96 config = ConfigParser.ConfigParser(allow_no_value=True)
97 config.readfp(io.StringIO(file_contents))
98 return config
99
100 def validate_config_data(self, sentry_unit, config_file, section,
101 expected):
102 """Validate config file data.
103
104 Verify that the specified section of the config file contains
105 the expected option key:value pairs.
106 """
107 config = self._get_config(sentry_unit, config_file)
108
109 if section != 'DEFAULT' and not config.has_section(section):
110 return "section [{}] does not exist".format(section)
111
112 for k in expected.keys():
113 if not config.has_option(section, k):
114 return "section [{}] is missing option {}".format(section, k)
115 if config.get(section, k) != expected[k]:
116 return "section [{}] {}:{} != expected {}:{}".format(
117 section, k, config.get(section, k), k, expected[k])
118 return None
119
120 def _validate_dict_data(self, expected, actual):
121 """Validate dictionary data.
122
123 Compare expected dictionary data vs actual dictionary data.
124 The values in the 'expected' dictionary can be strings, bools, ints,
125 longs, or can be a function that evaluate a variable and returns a
126 bool.
127 """
128 self.log.debug('actual: {}'.format(repr(actual)))
129 self.log.debug('expected: {}'.format(repr(expected)))
130
131 for k, v in six.iteritems(expected):
132 if k in actual:
133 if (isinstance(v, six.string_types) or
134 isinstance(v, bool) or
135 isinstance(v, six.integer_types)):
136 if v != actual[k]:
137 return "{}:{}".format(k, actual[k])
138 elif not v(actual[k]):
139 return "{}:{}".format(k, actual[k])
140 else:
141 return "key '{}' does not exist".format(k)
142 return None
143
144 def validate_relation_data(self, sentry_unit, relation, expected):
145 """Validate actual relation data based on expected relation data."""
146 actual = sentry_unit.relation(relation[0], relation[1])
147 return self._validate_dict_data(expected, actual)
148
149 def _validate_list_data(self, expected, actual):
150 """Compare expected list vs actual list data."""
151 for e in expected:
152 if e not in actual:
153 return "expected item {} not found in actual list".format(e)
154 return None
155
156 def not_null(self, string):
157 if string is not None:
158 return True
159 else:
160 return False
161
162 def _get_file_mtime(self, sentry_unit, filename):
163 """Get last modification time of file."""
164 return sentry_unit.file_stat(filename)['mtime']
165
166 def _get_dir_mtime(self, sentry_unit, directory):
167 """Get last modification time of directory."""
168 return sentry_unit.directory_stat(directory)['mtime']
169
170 def _get_proc_start_time(self, sentry_unit, service, pgrep_full=False):
171 """Get process' start time.
172
173 Determine start time of the process based on the last modification
174 time of the /proc/pid directory. If pgrep_full is True, the process
175 name is matched against the full command line.
176 """
177 if pgrep_full:
178 cmd = 'pgrep -o -f {}'.format(service)
179 else:
180 cmd = 'pgrep -o {}'.format(service)
181 cmd = cmd + ' | grep -v pgrep || exit 0'
182 cmd_out = sentry_unit.run(cmd)
183 self.log.debug('CMDout: ' + str(cmd_out))
184 if cmd_out[0]:
185 self.log.debug('Pid for %s %s' % (service, str(cmd_out[0])))
186 proc_dir = '/proc/{}'.format(cmd_out[0].strip())
187 return self._get_dir_mtime(sentry_unit, proc_dir)
188
189 def service_restarted(self, sentry_unit, service, filename,
190 pgrep_full=False, sleep_time=20):
191 """Check if service was restarted.
192
193 Compare a service's start time vs a file's last modification time
194 (such as a config file for that service) to determine if the service
195 has been restarted.
196 """
197 time.sleep(sleep_time)
198 if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
199 self._get_file_mtime(sentry_unit, filename)):
200 return True
201 else:
202 return False
203
204 def service_restarted_since(self, sentry_unit, mtime, service,
205 pgrep_full=False, sleep_time=20,
206 retry_count=2):
207 """Check if service was been started after a given time.
208
209 Args:
210 sentry_unit (sentry): The sentry unit to check for the service on
211 mtime (float): The epoch time to check against
212 service (string): service name to look for in process table
213 pgrep_full (boolean): Use full command line search mode with pgrep
214 sleep_time (int): Seconds to sleep before looking for process
215 retry_count (int): If service is not found, how many times to retry
216
217 Returns:
218 bool: True if service found and its start time it newer than mtime,
219 False if service is older than mtime or if service was
220 not found.
221 """
222 self.log.debug('Checking %s restarted since %s' % (service, mtime))
223 time.sleep(sleep_time)
224 proc_start_time = self._get_proc_start_time(sentry_unit, service,
225 pgrep_full)
226 while retry_count > 0 and not proc_start_time:
227 self.log.debug('No pid file found for service %s, will retry %i '
228 'more times' % (service, retry_count))
229 time.sleep(30)
230 proc_start_time = self._get_proc_start_time(sentry_unit, service,
231 pgrep_full)
232 retry_count = retry_count - 1
233
234 if not proc_start_time:
235 self.log.warn('No proc start time found, assuming service did '
236 'not start')
237 return False
238 if proc_start_time >= mtime:
239 self.log.debug('proc start time is newer than provided mtime'
240 '(%s >= %s)' % (proc_start_time, mtime))
241 return True
242 else:
243 self.log.warn('proc start time (%s) is older than provided mtime '
244 '(%s), service did not restart' % (proc_start_time,
245 mtime))
246 return False
247
248 def config_updated_since(self, sentry_unit, filename, mtime,
249 sleep_time=20):
250 """Check if file was modified after a given time.
251
252 Args:
253 sentry_unit (sentry): The sentry unit to check the file mtime on
254 filename (string): The file to check mtime of
255 mtime (float): The epoch time to check against
256 sleep_time (int): Seconds to sleep before looking for process
257
258 Returns:
259 bool: True if file was modified more recently than mtime, False if
260 file was modified before mtime,
261 """
262 self.log.debug('Checking %s updated since %s' % (filename, mtime))
263 time.sleep(sleep_time)
264 file_mtime = self._get_file_mtime(sentry_unit, filename)
265 if file_mtime >= mtime:
266 self.log.debug('File mtime is newer than provided mtime '
267 '(%s >= %s)' % (file_mtime, mtime))
268 return True
269 else:
270 self.log.warn('File mtime %s is older than provided mtime %s'
271 % (file_mtime, mtime))
272 return False
273
274 def validate_service_config_changed(self, sentry_unit, mtime, service,
275 filename, pgrep_full=False,
276 sleep_time=20, retry_count=2):
277 """Check service and file were updated after mtime
278
279 Args:
280 sentry_unit (sentry): The sentry unit to check for the service on
281 mtime (float): The epoch time to check against
282 service (string): service name to look for in process table
283 filename (string): The file to check mtime of
284 pgrep_full (boolean): Use full command line search mode with pgrep
285 sleep_time (int): Seconds to sleep before looking for process
286 retry_count (int): If service is not found, how many times to retry
287
288 Typical Usage:
289 u = OpenStackAmuletUtils(ERROR)
290 ...
291 mtime = u.get_sentry_time(self.cinder_sentry)
292 self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
293 if not u.validate_service_config_changed(self.cinder_sentry,
294 mtime,
295 'cinder-api',
296 '/etc/cinder/cinder.conf')
297 amulet.raise_status(amulet.FAIL, msg='update failed')
298 Returns:
299 bool: True if both service and file where updated/restarted after
300 mtime, False if service is older than mtime or if service was
301 not found or if filename was modified before mtime.
302 """
303 self.log.debug('Checking %s restarted since %s' % (service, mtime))
304 time.sleep(sleep_time)
305 service_restart = self.service_restarted_since(sentry_unit, mtime,
306 service,
307 pgrep_full=pgrep_full,
308 sleep_time=0,
309 retry_count=retry_count)
310 config_update = self.config_updated_since(sentry_unit, filename, mtime,
311 sleep_time=0)
312 return service_restart and config_update
313
314 def get_sentry_time(self, sentry_unit):
315 """Return current epoch time on a sentry"""
316 cmd = "date +'%s'"
317 return float(sentry_unit.run(cmd)[0])
318
319 def relation_error(self, name, data):
320 return 'unexpected relation data in {} - {}'.format(name, data)
321
322 def endpoint_error(self, name, data):
323 return 'unexpected endpoint data in {} - {}'.format(name, data)
0324
=== added file 'tests/tests.yaml'
--- tests/tests.yaml 1970-01-01 00:00:00 +0000
+++ tests/tests.yaml 2015-04-30 13:14:23 +0000
@@ -0,0 +1,9 @@
1bootstrap: true
2reset: true
3virtualenv: true
4makefile:
5 - lint
6sources:
7 - ppa:juju/stable
8packages:
9 - amulet

Subscribers

People subscribed via source and target branches

to all changes: