Merge ~pwlars/revcache/+git/revcache-charm:juju1-support into revcache:master

Proposed by Paul Larson
Status: Superseded
Proposed branch: ~pwlars/revcache/+git/revcache-charm:juju1-support
Merge into: revcache:master
Diff against target: 6772 lines (+6450/-0)
54 files modified
.gitignore (+3/-0)
README.md (+221/-0)
config.yaml (+21/-0)
copyright (+9/-0)
hooks/charmhelpers/__init__.py (+36/-0)
hooks/charmhelpers/contrib/__init__.py (+13/-0)
hooks/charmhelpers/contrib/ansible/__init__.py (+252/-0)
hooks/charmhelpers/contrib/templating/__init__.py (+13/-0)
hooks/charmhelpers/contrib/templating/contexts.py (+137/-0)
hooks/charmhelpers/core/__init__.py (+13/-0)
hooks/charmhelpers/core/decorators.py (+55/-0)
hooks/charmhelpers/core/files.py (+43/-0)
hooks/charmhelpers/core/fstab.py (+132/-0)
hooks/charmhelpers/core/hookenv.py (+1068/-0)
hooks/charmhelpers/core/host.py (+922/-0)
hooks/charmhelpers/core/host_factory/__init__.py (+0/-0)
hooks/charmhelpers/core/host_factory/centos.py (+72/-0)
hooks/charmhelpers/core/host_factory/ubuntu.py (+88/-0)
hooks/charmhelpers/core/hugepage.py (+69/-0)
hooks/charmhelpers/core/kernel.py (+72/-0)
hooks/charmhelpers/core/kernel_factory/__init__.py (+0/-0)
hooks/charmhelpers/core/kernel_factory/centos.py (+17/-0)
hooks/charmhelpers/core/kernel_factory/ubuntu.py (+13/-0)
hooks/charmhelpers/core/services/__init__.py (+16/-0)
hooks/charmhelpers/core/services/base.py (+351/-0)
hooks/charmhelpers/core/services/helpers.py (+290/-0)
hooks/charmhelpers/core/strutils.py (+123/-0)
hooks/charmhelpers/core/sysctl.py (+54/-0)
hooks/charmhelpers/core/templating.py (+84/-0)
hooks/charmhelpers/core/unitdata.py (+518/-0)
hooks/charmhelpers/fetch/__init__.py (+197/-0)
hooks/charmhelpers/fetch/archiveurl.py (+165/-0)
hooks/charmhelpers/fetch/bzrurl.py (+76/-0)
hooks/charmhelpers/fetch/centos.py (+171/-0)
hooks/charmhelpers/fetch/giturl.py (+69/-0)
hooks/charmhelpers/fetch/snap.py (+122/-0)
hooks/charmhelpers/fetch/ubuntu.py (+364/-0)
hooks/charmhelpers/osplatform.py (+25/-0)
hooks/charmhelpers/payload/__init__.py (+15/-0)
hooks/charmhelpers/payload/archive.py (+71/-0)
hooks/charmhelpers/payload/execd.py (+65/-0)
hooks/config-changed (+21/-0)
hooks/db-relation-changed (+21/-0)
hooks/db-relation-joined (+21/-0)
hooks/hooks.py (+21/-0)
hooks/install (+21/-0)
hooks/start (+21/-0)
hooks/stop (+21/-0)
metadata.yaml (+23/-0)
playbooks/revcache.yaml (+194/-0)
templates/revcache-vhost-https.conf (+14/-0)
templates/revcache-vhost.conf (+12/-0)
templates/revcache.conf (+2/-0)
templates/revcache.service (+13/-0)
Reviewer Review Type Date Requested Status
Canonical Hardware Certification Pending
Review via email: mp+348162@code.launchpad.net

This proposal has been superseded by a proposal from 2018-06-20.

Description of the change

I think this finally does what I want. I've tested it locally with some fake keys, and confirmed that it puts the files in the right location and uses the correct vhost template with ssl support for nginx *only* when specifying the certificates and ssl key in either the config or in an artifact. This allows us to support both juju1 and juju2 in the same charm!

To post a comment you must log in.

Unmerged commits

fff4d98... by Paul Larson

Also support ssl on juju v1 via config settings and include-base64

5d56cf8... by PMR <pmr@pmr-lander>

Merge #346656 from ~pwlars/revcache/+git/revcache-charm:ssl-support

a5135d1... by Paul Larson

Add https support

daf9c5e... by Paul Larson

Add python3-gevent dependency and minor fixes to systemd service file

76d9edd... by Paul Larson

Remove setup step and ensure logfile is created

d0098af... by Paul Larson

Initial checking of revcache charm

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000..021454b
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,3 @@
7+*.pyc
8+*~
9+*.swp
10diff --git a/README.md b/README.md
11new file mode 100644
12index 0000000..0337c83
13--- /dev/null
14+++ b/README.md
15@@ -0,0 +1,221 @@
16+# Overview
17+
18+This is the base layer for all charms [built using layers][building]. It
19+provides all of the standard Juju hooks and runs the
20+[charms.reactive.main][charms.reactive] loop for them. It also bootstraps the
21+[charm-helpers][] and [charms.reactive][] libraries and all of their
22+dependencies for use by the charm.
23+
24+# Usage
25+
26+To create a charm layer using this base layer, you need only include it in
27+a `layer.yaml` file:
28+
29+```yaml
30+includes: ['layer:basic']
31+```
32+
33+This will fetch this layer from [interfaces.juju.solutions][] and incorporate
34+it into your charm layer. You can then add handlers under the `reactive/`
35+directory. Note that **any** file under `reactive/` will be expected to
36+contain handlers, whether as Python decorated functions or [executables][non-python]
37+using the [external handler protocol][].
38+
39+### Charm Dependencies
40+
41+Each layer can include a `wheelhouse.txt` file with Python requirement lines.
42+For example, this layer's `wheelhouse.txt` includes:
43+
44+```
45+pip>=7.0.0,<8.0.0
46+charmhelpers>=0.4.0,<1.0.0
47+charms.reactive>=0.1.0,<2.0.0
48+```
49+
50+All of these dependencies from each layer will be fetched (and updated) at build
51+time and will be automatically installed by this base layer before any reactive
52+handlers are run.
53+
54+Note that the `wheelhouse.txt` file is intended for **charm** dependencies only.
55+That is, for libraries that the charm code itself needs to do its job of deploying
56+and configuring the payload. If the payload itself has Python dependencies, those
57+should be handled separately, by the charm.
58+
59+See [PyPI][pypi charms.X] for packages under the `charms.` namespace which might
60+be useful for your charm.
61+
62+### Layer Namespace
63+
64+Each layer has a reserved section in the `charms.layer.` Python package namespace,
65+which it can populate by including a `lib/charms/layer/<layer-name>.py` file or
66+by placing files under `lib/charms/layer/<layer-name>/`. (If the layer name
67+includes hyphens, replace them with underscores.) These can be helpers that the
68+layer uses internally, or it can expose classes or functions to be used by other
69+layers to interact with that layer.
70+
71+For example, a layer named `foo` could include a `lib/charms/layer/foo.py` file
72+with some helper functions that other layers could access using:
73+
74+```python
75+from charms.layer.foo import my_helper
76+```
77+
78+### Layer Options
79+
80+Any layer can define options in its `layer.yaml`. Those options can then be set
81+by other layers to change the behavior of your layer. The options are defined
82+using [jsonschema][], which is the same way that [action paramters][] are defined.
83+
84+For example, the `foo` layer could include the following option definitons:
85+
86+```yaml
87+includes: ['layer:basic']
88+defines: # define some options for this layer (the layer "foo")
89+ enable-bar: # define an "enable-bar" option for this layer
90+ description: If true, enable support for "bar".
91+ type: boolean
92+ default: false
93+```
94+
95+A layer using `foo` could then set it:
96+
97+```yaml
98+includes: ['layer:foo']
99+options:
100+ foo: # setting options for the "foo" layer
101+ enable-bar: true # set the "enable-bar" option to true
102+```
103+
104+The `foo` layer can then use the `charms.layer.options` helper to load the values
105+for the options that it defined. For example:
106+
107+```python
108+from charms import layer
109+
110+@when('state')
111+def do_thing():
112+ layer_opts = layer.options('foo') # load all of the options for the "foo" layer
113+ if layer_opts['enable-bar']: # check the value of the "enable-bar" option
114+ hookenv.log("Bar is enabled")
115+```
116+
117+You can also access layer options in other handlers, such as Bash, using
118+the command-line interface:
119+
120+```bash
121+. charms.reactive.sh
122+
123+@when 'state'
124+function do_thing() {
125+ if layer_option foo enable-bar; then
126+ juju-log "Bar is enabled"
127+ juju-log "bar-value is: $(layer_option foo bar-value)"
128+ fi
129+}
130+
131+reactive_handler_main
132+```
133+
134+Note that options of type `boolean` will set the exit code, while other types
135+will be printed out.
136+
137+# Hooks
138+
139+This layer provides hooks that other layers can react to using the decorators
140+of the [charms.reactive][] library:
141+
142+ * `config-changed`
143+ * `install`
144+ * `leader-elected`
145+ * `leader-settings-changed`
146+ * `start`
147+ * `stop`
148+ * `upgrade-charm`
149+ * `update-status`
150+
151+Other hooks are not implemented at this time. A new layer can implement storage
152+or relation hooks in their own layer by putting them in the `hooks` directory.
153+
154+**Note:** Because `update-status` is invoked every 5 minutes, you should take
155+care to ensure that your reactive handlers only invoke expensive operations
156+when absolutely necessary. It is recommended that you use helpers like
157+[`@only_once`][], [`@when_file_changed`][], and [`data_changed`][] to ensure
158+that handlers run only when necessary.
159+
160+# Layer Configuration
161+
162+This layer supports the following options, which can be set in `layer.yaml`:
163+
164+ * **packages** A list of system packages to be installed before the reactive
165+ handlers are invoked.
166+
167+ * **use_venv** If set to true, the charm dependencies from the various
168+ layers' `wheelhouse.txt` files will be installed in a Python virtualenv
169+ located at `$CHARM_DIR/../.venv`. This keeps charm dependencies from
170+ conflicting with payload dependencies, but you must take care to preserve
171+ the environment and interpreter if using `execl` or `subprocess`.
172+
173+ * **include_system_packages** If set to true and using a venv, include
174+ the `--system-site-packages` options to make system Python libraries
175+ visible within the venv.
176+
177+An example `layer.yaml` using these options might be:
178+
179+```yaml
180+includes: ['layer:basic']
181+options:
182+ basic:
183+ packages: ['git']
184+ use_venv: true
185+ include_system_packages: true
186+```
187+
188+
189+# Reactive States
190+
191+This layer will set the following states:
192+
193+ * **`config.changed`** Any config option has changed from its previous value.
194+ This state is cleared automatically at the end of each hook invocation.
195+
196+ * **`config.changed.<option>`** A specific config option has changed.
197+ **`<option>`** will be replaced by the config option name from `config.yaml`.
198+ This state is cleared automatically at the end of each hook invocation.
199+
200+ * **`config.set.<option>`** A specific config option has a True or non-empty
201+ value set. **`<option>`** will be replaced by the config option name from
202+ `config.yaml`. This state is cleared automatically at the end of each hook
203+ invocation.
204+
205+ * **`config.default.<option>`** A specific config option is set to its
206+ default value. **`<option>`** will be replaced by the config option name
207+ from `config.yaml`. This state is cleared automatically at the end of
208+ each hook invocation.
209+
210+An example using the config states would be:
211+
212+```python
213+@when('config.changed.my-opt')
214+def my_opt_changed():
215+ update_config()
216+ restart_service()
217+```
218+
219+
220+# Actions
221+
222+This layer currently does not define any actions.
223+
224+
225+[building]: https://jujucharms.com/docs/devel/authors-charm-building
226+[charm-helpers]: https://pythonhosted.org/charmhelpers/
227+[charms.reactive]: https://pythonhosted.org/charms.reactive/
228+[interfaces.juju.solutions]: http://interfaces.juju.solutions/
229+[non-python]: https://pythonhosted.org/charms.reactive/#non-python-reactive-handlers
230+[external handler protocol]: https://pythonhosted.org/charms.reactive/charms.reactive.bus.html#charms.reactive.bus.ExternalHandler
231+[jsonschema]: http://json-schema.org/
232+[action paramters]: https://jujucharms.com/docs/stable/authors-charm-actions
233+[pypi charms.X]: https://pypi.python.org/pypi?%3Aaction=search&term=charms.&submit=search
234+[`@only_once`]: https://pythonhosted.org/charms.reactive/charms.reactive.decorators.html#charms.reactive.decorators.only_once
235+[`@when_file_changed`]: https://pythonhosted.org/charms.reactive/charms.reactive.decorators.html#charms.reactive.decorators.when_file_changed
236+[`data_changed`]: https://pythonhosted.org/charms.reactive/charms.reactive.helpers.html#charms.reactive.helpers.data_changed
237diff --git a/config.yaml b/config.yaml
238new file mode 100644
239index 0000000..1e4601f
240--- /dev/null
241+++ b/config.yaml
242@@ -0,0 +1,21 @@
243+options:
244+ revcache-repo:
245+ type: string
246+ description: git repo for revcache
247+ default: "https://git.launchpad.net/~pwlars/revcache"
248+ revcache-branch:
249+ type: string
250+ description: git branch for revcache
251+ default: "start"
252+ config-ssl-certificate:
253+ type: string
254+ description: base64 encoded ssl certificate file
255+ default: ""
256+ config-ssl-chain:
257+ type: string
258+ description: base64 encoded ssl chain file
259+ default: ""
260+ config-ssl-key:
261+ type: string
262+ description: base64 encoded ssl key file
263+ default: ""
264diff --git a/copyright b/copyright
265new file mode 100644
266index 0000000..4aca5f0
267--- /dev/null
268+++ b/copyright
269@@ -0,0 +1,9 @@
270+Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0
271+
272+Files: *
273+Copyright: 2018, Canonical Ltd.
274+License: GPL-3
275+
276+License: GPL-3
277+ On Debian GNU/Linux system you can find the complete text of the
278+ GPL-3 license in '/usr/share/common-licenses/GPL-3'
279diff --git a/hooks/charmhelpers/__init__.py b/hooks/charmhelpers/__init__.py
280new file mode 100644
281index 0000000..4886788
282--- /dev/null
283+++ b/hooks/charmhelpers/__init__.py
284@@ -0,0 +1,36 @@
285+# Copyright 2014-2015 Canonical Limited.
286+#
287+# Licensed under the Apache License, Version 2.0 (the "License");
288+# you may not use this file except in compliance with the License.
289+# You may obtain a copy of the License at
290+#
291+# http://www.apache.org/licenses/LICENSE-2.0
292+#
293+# Unless required by applicable law or agreed to in writing, software
294+# distributed under the License is distributed on an "AS IS" BASIS,
295+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
296+# See the License for the specific language governing permissions and
297+# limitations under the License.
298+
299+# Bootstrap charm-helpers, installing its dependencies if necessary using
300+# only standard libraries.
301+import subprocess
302+import sys
303+
304+try:
305+ import six # flake8: noqa
306+except ImportError:
307+ if sys.version_info.major == 2:
308+ subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
309+ else:
310+ subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
311+ import six # flake8: noqa
312+
313+try:
314+ import yaml # flake8: noqa
315+except ImportError:
316+ if sys.version_info.major == 2:
317+ subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
318+ else:
319+ subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
320+ import yaml # flake8: noqa
321diff --git a/hooks/charmhelpers/contrib/__init__.py b/hooks/charmhelpers/contrib/__init__.py
322new file mode 100644
323index 0000000..d7567b8
324--- /dev/null
325+++ b/hooks/charmhelpers/contrib/__init__.py
326@@ -0,0 +1,13 @@
327+# Copyright 2014-2015 Canonical Limited.
328+#
329+# Licensed under the Apache License, Version 2.0 (the "License");
330+# you may not use this file except in compliance with the License.
331+# You may obtain a copy of the License at
332+#
333+# http://www.apache.org/licenses/LICENSE-2.0
334+#
335+# Unless required by applicable law or agreed to in writing, software
336+# distributed under the License is distributed on an "AS IS" BASIS,
337+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
338+# See the License for the specific language governing permissions and
339+# limitations under the License.
340diff --git a/hooks/charmhelpers/contrib/ansible/__init__.py b/hooks/charmhelpers/contrib/ansible/__init__.py
341new file mode 100644
342index 0000000..f3617db
343--- /dev/null
344+++ b/hooks/charmhelpers/contrib/ansible/__init__.py
345@@ -0,0 +1,252 @@
346+# Copyright 2014-2015 Canonical Limited.
347+#
348+# Licensed under the Apache License, Version 2.0 (the "License");
349+# you may not use this file except in compliance with the License.
350+# You may obtain a copy of the License at
351+#
352+# http://www.apache.org/licenses/LICENSE-2.0
353+#
354+# Unless required by applicable law or agreed to in writing, software
355+# distributed under the License is distributed on an "AS IS" BASIS,
356+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
357+# See the License for the specific language governing permissions and
358+# limitations under the License.
359+
360+# Copyright 2013 Canonical Ltd.
361+#
362+# Authors:
363+# Charm Helpers Developers <juju@lists.ubuntu.com>
364+"""Charm Helpers ansible - declare the state of your machines.
365+
366+This helper enables you to declare your machine state, rather than
367+program it procedurally (and have to test each change to your procedures).
368+Your install hook can be as simple as::
369+
370+ {{{
371+ import charmhelpers.contrib.ansible
372+
373+
374+ def install():
375+ charmhelpers.contrib.ansible.install_ansible_support()
376+ charmhelpers.contrib.ansible.apply_playbook('playbooks/install.yaml')
377+ }}}
378+
379+and won't need to change (nor will its tests) when you change the machine
380+state.
381+
382+All of your juju config and relation-data are available as template
383+variables within your playbooks and templates. An install playbook looks
384+something like::
385+
386+ {{{
387+ ---
388+ - hosts: localhost
389+ user: root
390+
391+ tasks:
392+ - name: Add private repositories.
393+ template:
394+ src: ../templates/private-repositories.list.jinja2
395+ dest: /etc/apt/sources.list.d/private.list
396+
397+ - name: Update the cache.
398+ apt: update_cache=yes
399+
400+ - name: Install dependencies.
401+ apt: pkg={{ item }}
402+ with_items:
403+ - python-mimeparse
404+ - python-webob
405+ - sunburnt
406+
407+ - name: Setup groups.
408+ group: name={{ item.name }} gid={{ item.gid }}
409+ with_items:
410+ - { name: 'deploy_user', gid: 1800 }
411+ - { name: 'service_user', gid: 1500 }
412+
413+ ...
414+ }}}
415+
416+Read more online about `playbooks`_ and standard ansible `modules`_.
417+
418+.. _playbooks: http://www.ansibleworks.com/docs/playbooks.html
419+.. _modules: http://www.ansibleworks.com/docs/modules.html
420+
421+A further feature os the ansible hooks is to provide a light weight "action"
422+scripting tool. This is a decorator that you apply to a function, and that
423+function can now receive cli args, and can pass extra args to the playbook.
424+
425+e.g.
426+
427+
428+@hooks.action()
429+def some_action(amount, force="False"):
430+ "Usage: some-action AMOUNT [force=True]" # <-- shown on error
431+ # process the arguments
432+ # do some calls
433+ # return extra-vars to be passed to ansible-playbook
434+ return {
435+ 'amount': int(amount),
436+ 'type': force,
437+ }
438+
439+You can now create a symlink to hooks.py that can be invoked like a hook, but
440+with cli params:
441+
442+# link actions/some-action to hooks/hooks.py
443+
444+actions/some-action amount=10 force=true
445+
446+"""
447+import os
448+import stat
449+import subprocess
450+import functools
451+
452+import charmhelpers.contrib.templating.contexts
453+import charmhelpers.core.host
454+import charmhelpers.core.hookenv
455+import charmhelpers.fetch
456+
457+
458+charm_dir = os.environ.get('CHARM_DIR', '')
459+ansible_hosts_path = '/etc/ansible/hosts'
460+# Ansible will automatically include any vars in the following
461+# file in its inventory when run locally.
462+ansible_vars_path = '/etc/ansible/host_vars/localhost'
463+
464+
465+def install_ansible_support(from_ppa=True, ppa_location='ppa:rquillo/ansible'):
466+ """Installs the ansible package.
467+
468+ By default it is installed from the `PPA`_ linked from
469+ the ansible `website`_ or from a ppa specified by a charm config..
470+
471+ .. _PPA: https://launchpad.net/~rquillo/+archive/ansible
472+ .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu
473+
474+ If from_ppa is empty, you must ensure that the package is available
475+ from a configured repository.
476+ """
477+ if from_ppa:
478+ charmhelpers.fetch.add_source(ppa_location)
479+ charmhelpers.fetch.apt_update(fatal=True)
480+ charmhelpers.fetch.apt_install('ansible')
481+ with open(ansible_hosts_path, 'w+') as hosts_file:
482+ hosts_file.write('localhost ansible_connection=local')
483+
484+
485+def apply_playbook(playbook, tags=None, extra_vars=None):
486+ tags = tags or []
487+ tags = ",".join(tags)
488+ charmhelpers.contrib.templating.contexts.juju_state_to_yaml(
489+ ansible_vars_path, namespace_separator='__',
490+ allow_hyphens_in_keys=False, mode=(stat.S_IRUSR | stat.S_IWUSR))
491+
492+ # we want ansible's log output to be unbuffered
493+ env = os.environ.copy()
494+ env['PYTHONUNBUFFERED'] = "1"
495+ call = [
496+ 'ansible-playbook',
497+ '-c',
498+ 'local',
499+ playbook,
500+ ]
501+ if tags:
502+ call.extend(['--tags', '{}'.format(tags)])
503+ if extra_vars:
504+ extra = ["%s=%s" % (k, v) for k, v in extra_vars.items()]
505+ call.extend(['--extra-vars', " ".join(extra)])
506+ subprocess.check_call(call, env=env)
507+
508+
509+class AnsibleHooks(charmhelpers.core.hookenv.Hooks):
510+ """Run a playbook with the hook-name as the tag.
511+
512+ This helper builds on the standard hookenv.Hooks helper,
513+ but additionally runs the playbook with the hook-name specified
514+ using --tags (ie. running all the tasks tagged with the hook-name).
515+
516+ Example::
517+
518+ hooks = AnsibleHooks(playbook_path='playbooks/my_machine_state.yaml')
519+
520+ # All the tasks within my_machine_state.yaml tagged with 'install'
521+ # will be run automatically after do_custom_work()
522+ @hooks.hook()
523+ def install():
524+ do_custom_work()
525+
526+ # For most of your hooks, you won't need to do anything other
527+ # than run the tagged tasks for the hook:
528+ @hooks.hook('config-changed', 'start', 'stop')
529+ def just_use_playbook():
530+ pass
531+
532+ # As a convenience, you can avoid the above noop function by specifying
533+ # the hooks which are handled by ansible-only and they'll be registered
534+ # for you:
535+ # hooks = AnsibleHooks(
536+ # 'playbooks/my_machine_state.yaml',
537+ # default_hooks=['config-changed', 'start', 'stop'])
538+
539+ if __name__ == "__main__":
540+ # execute a hook based on the name the program is called by
541+ hooks.execute(sys.argv)
542+
543+ """
544+
545+ def __init__(self, playbook_path, default_hooks=None):
546+ """Register any hooks handled by ansible."""
547+ super(AnsibleHooks, self).__init__()
548+
549+ self._actions = {}
550+ self.playbook_path = playbook_path
551+
552+ default_hooks = default_hooks or []
553+
554+ def noop(*args, **kwargs):
555+ pass
556+
557+ for hook in default_hooks:
558+ self.register(hook, noop)
559+
560+ def register_action(self, name, function):
561+ """Register a hook"""
562+ self._actions[name] = function
563+
564+ def execute(self, args):
565+ """Execute the hook followed by the playbook using the hook as tag."""
566+ hook_name = os.path.basename(args[0])
567+ extra_vars = None
568+ if hook_name in self._actions:
569+ extra_vars = self._actions[hook_name](args[1:])
570+ else:
571+ super(AnsibleHooks, self).execute(args)
572+
573+ charmhelpers.contrib.ansible.apply_playbook(
574+ self.playbook_path, tags=[hook_name], extra_vars=extra_vars)
575+
576+ def action(self, *action_names):
577+ """Decorator, registering them as actions"""
578+ def action_wrapper(decorated):
579+
580+ @functools.wraps(decorated)
581+ def wrapper(argv):
582+ kwargs = dict(arg.split('=') for arg in argv)
583+ try:
584+ return decorated(**kwargs)
585+ except TypeError as e:
586+ if decorated.__doc__:
587+ e.args += (decorated.__doc__,)
588+ raise
589+
590+ self.register_action(decorated.__name__, wrapper)
591+ if '_' in decorated.__name__:
592+ self.register_action(
593+ decorated.__name__.replace('_', '-'), wrapper)
594+
595+ return wrapper
596+
597+ return action_wrapper
598diff --git a/hooks/charmhelpers/contrib/templating/__init__.py b/hooks/charmhelpers/contrib/templating/__init__.py
599new file mode 100644
600index 0000000..d7567b8
601--- /dev/null
602+++ b/hooks/charmhelpers/contrib/templating/__init__.py
603@@ -0,0 +1,13 @@
604+# Copyright 2014-2015 Canonical Limited.
605+#
606+# Licensed under the Apache License, Version 2.0 (the "License");
607+# you may not use this file except in compliance with the License.
608+# You may obtain a copy of the License at
609+#
610+# http://www.apache.org/licenses/LICENSE-2.0
611+#
612+# Unless required by applicable law or agreed to in writing, software
613+# distributed under the License is distributed on an "AS IS" BASIS,
614+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
615+# See the License for the specific language governing permissions and
616+# limitations under the License.
617diff --git a/hooks/charmhelpers/contrib/templating/contexts.py b/hooks/charmhelpers/contrib/templating/contexts.py
618new file mode 100644
619index 0000000..c1adf94
620--- /dev/null
621+++ b/hooks/charmhelpers/contrib/templating/contexts.py
622@@ -0,0 +1,137 @@
623+# Copyright 2014-2015 Canonical Limited.
624+#
625+# Licensed under the Apache License, Version 2.0 (the "License");
626+# you may not use this file except in compliance with the License.
627+# You may obtain a copy of the License at
628+#
629+# http://www.apache.org/licenses/LICENSE-2.0
630+#
631+# Unless required by applicable law or agreed to in writing, software
632+# distributed under the License is distributed on an "AS IS" BASIS,
633+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
634+# See the License for the specific language governing permissions and
635+# limitations under the License.
636+
637+# Copyright 2013 Canonical Ltd.
638+#
639+# Authors:
640+# Charm Helpers Developers <juju@lists.ubuntu.com>
641+"""A helper to create a yaml cache of config with namespaced relation data."""
642+import os
643+import yaml
644+
645+import six
646+
647+import charmhelpers.core.hookenv
648+
649+
650+charm_dir = os.environ.get('CHARM_DIR', '')
651+
652+
653+def dict_keys_without_hyphens(a_dict):
654+ """Return the a new dict with underscores instead of hyphens in keys."""
655+ return dict(
656+ (key.replace('-', '_'), val) for key, val in a_dict.items())
657+
658+
659+def update_relations(context, namespace_separator=':'):
660+ """Update the context with the relation data."""
661+ # Add any relation data prefixed with the relation type.
662+ relation_type = charmhelpers.core.hookenv.relation_type()
663+ relations = []
664+ context['current_relation'] = {}
665+ if relation_type is not None:
666+ relation_data = charmhelpers.core.hookenv.relation_get()
667+ context['current_relation'] = relation_data
668+ # Deprecated: the following use of relation data as keys
669+ # directly in the context will be removed.
670+ relation_data = dict(
671+ ("{relation_type}{namespace_separator}{key}".format(
672+ relation_type=relation_type,
673+ key=key,
674+ namespace_separator=namespace_separator), val)
675+ for key, val in relation_data.items())
676+ relation_data = dict_keys_without_hyphens(relation_data)
677+ context.update(relation_data)
678+ relations = charmhelpers.core.hookenv.relations_of_type(relation_type)
679+ relations = [dict_keys_without_hyphens(rel) for rel in relations]
680+
681+ context['relations_full'] = charmhelpers.core.hookenv.relations()
682+
683+ # the hookenv.relations() data structure is effectively unusable in
684+ # templates and other contexts when trying to access relation data other
685+ # than the current relation. So provide a more useful structure that works
686+ # with any hook.
687+ local_unit = charmhelpers.core.hookenv.local_unit()
688+ relations = {}
689+ for rname, rids in context['relations_full'].items():
690+ relations[rname] = []
691+ for rid, rdata in rids.items():
692+ data = rdata.copy()
693+ if local_unit in rdata:
694+ data.pop(local_unit)
695+ for unit_name, rel_data in data.items():
696+ new_data = {'__relid__': rid, '__unit__': unit_name}
697+ new_data.update(rel_data)
698+ relations[rname].append(new_data)
699+ context['relations'] = relations
700+
701+
702+def juju_state_to_yaml(yaml_path, namespace_separator=':',
703+ allow_hyphens_in_keys=True, mode=None):
704+ """Update the juju config and state in a yaml file.
705+
706+ This includes any current relation-get data, and the charm
707+ directory.
708+
709+ This function was created for the ansible and saltstack
710+ support, as those libraries can use a yaml file to supply
711+ context to templates, but it may be useful generally to
712+ create and update an on-disk cache of all the config, including
713+ previous relation data.
714+
715+ By default, hyphens are allowed in keys as this is supported
716+ by yaml, but for tools like ansible, hyphens are not valid [1].
717+
718+ [1] http://www.ansibleworks.com/docs/playbooks_variables.html#what-makes-a-valid-variable-name
719+ """
720+ config = charmhelpers.core.hookenv.config()
721+
722+ # Add the charm_dir which we will need to refer to charm
723+ # file resources etc.
724+ config['charm_dir'] = charm_dir
725+ config['local_unit'] = charmhelpers.core.hookenv.local_unit()
726+ config['unit_private_address'] = charmhelpers.core.hookenv.unit_private_ip()
727+ config['unit_public_address'] = charmhelpers.core.hookenv.unit_get(
728+ 'public-address'
729+ )
730+
731+ # Don't use non-standard tags for unicode which will not
732+ # work when salt uses yaml.load_safe.
733+ yaml.add_representer(six.text_type,
734+ lambda dumper, value: dumper.represent_scalar(
735+ six.u('tag:yaml.org,2002:str'), value))
736+
737+ yaml_dir = os.path.dirname(yaml_path)
738+ if not os.path.exists(yaml_dir):
739+ os.makedirs(yaml_dir)
740+
741+ if os.path.exists(yaml_path):
742+ with open(yaml_path, "r") as existing_vars_file:
743+ existing_vars = yaml.load(existing_vars_file.read())
744+ else:
745+ with open(yaml_path, "w+"):
746+ pass
747+ existing_vars = {}
748+
749+ if mode is not None:
750+ os.chmod(yaml_path, mode)
751+
752+ if not allow_hyphens_in_keys:
753+ config = dict_keys_without_hyphens(config)
754+ existing_vars.update(config)
755+
756+ update_relations(existing_vars, namespace_separator)
757+
758+ with open(yaml_path, "w+") as fp:
759+ fp.write(yaml.dump(existing_vars, default_flow_style=False))
760diff --git a/hooks/charmhelpers/core/__init__.py b/hooks/charmhelpers/core/__init__.py
761new file mode 100644
762index 0000000..d7567b8
763--- /dev/null
764+++ b/hooks/charmhelpers/core/__init__.py
765@@ -0,0 +1,13 @@
766+# Copyright 2014-2015 Canonical Limited.
767+#
768+# Licensed under the Apache License, Version 2.0 (the "License");
769+# you may not use this file except in compliance with the License.
770+# You may obtain a copy of the License at
771+#
772+# http://www.apache.org/licenses/LICENSE-2.0
773+#
774+# Unless required by applicable law or agreed to in writing, software
775+# distributed under the License is distributed on an "AS IS" BASIS,
776+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
777+# See the License for the specific language governing permissions and
778+# limitations under the License.
779diff --git a/hooks/charmhelpers/core/decorators.py b/hooks/charmhelpers/core/decorators.py
780new file mode 100644
781index 0000000..6ad41ee
782--- /dev/null
783+++ b/hooks/charmhelpers/core/decorators.py
784@@ -0,0 +1,55 @@
785+# Copyright 2014-2015 Canonical Limited.
786+#
787+# Licensed under the Apache License, Version 2.0 (the "License");
788+# you may not use this file except in compliance with the License.
789+# You may obtain a copy of the License at
790+#
791+# http://www.apache.org/licenses/LICENSE-2.0
792+#
793+# Unless required by applicable law or agreed to in writing, software
794+# distributed under the License is distributed on an "AS IS" BASIS,
795+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
796+# See the License for the specific language governing permissions and
797+# limitations under the License.
798+
799+#
800+# Copyright 2014 Canonical Ltd.
801+#
802+# Authors:
803+# Edward Hope-Morley <opentastic@gmail.com>
804+#
805+
806+import time
807+
808+from charmhelpers.core.hookenv import (
809+ log,
810+ INFO,
811+)
812+
813+
814+def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
815+ """If the decorated function raises exception exc_type, allow num_retries
816+ retry attempts before raise the exception.
817+ """
818+ def _retry_on_exception_inner_1(f):
819+ def _retry_on_exception_inner_2(*args, **kwargs):
820+ retries = num_retries
821+ multiplier = 1
822+ while True:
823+ try:
824+ return f(*args, **kwargs)
825+ except exc_type:
826+ if not retries:
827+ raise
828+
829+ delay = base_delay * multiplier
830+ multiplier += 1
831+ log("Retrying '%s' %d more times (delay=%s)" %
832+ (f.__name__, retries, delay), level=INFO)
833+ retries -= 1
834+ if delay:
835+ time.sleep(delay)
836+
837+ return _retry_on_exception_inner_2
838+
839+ return _retry_on_exception_inner_1
840diff --git a/hooks/charmhelpers/core/files.py b/hooks/charmhelpers/core/files.py
841new file mode 100644
842index 0000000..fdd82b7
843--- /dev/null
844+++ b/hooks/charmhelpers/core/files.py
845@@ -0,0 +1,43 @@
846+#!/usr/bin/env python
847+# -*- coding: utf-8 -*-
848+
849+# Copyright 2014-2015 Canonical Limited.
850+#
851+# Licensed under the Apache License, Version 2.0 (the "License");
852+# you may not use this file except in compliance with the License.
853+# You may obtain a copy of the License at
854+#
855+# http://www.apache.org/licenses/LICENSE-2.0
856+#
857+# Unless required by applicable law or agreed to in writing, software
858+# distributed under the License is distributed on an "AS IS" BASIS,
859+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
860+# See the License for the specific language governing permissions and
861+# limitations under the License.
862+
863+__author__ = 'Jorge Niedbalski <niedbalski@ubuntu.com>'
864+
865+import os
866+import subprocess
867+
868+
869+def sed(filename, before, after, flags='g'):
870+ """
871+ Search and replaces the given pattern on filename.
872+
873+ :param filename: relative or absolute file path.
874+ :param before: expression to be replaced (see 'man sed')
875+ :param after: expression to replace with (see 'man sed')
876+ :param flags: sed-compatible regex flags in example, to make
877+ the search and replace case insensitive, specify ``flags="i"``.
878+ The ``g`` flag is always specified regardless, so you do not
879+ need to remember to include it when overriding this parameter.
880+ :returns: If the sed command exit code was zero then return,
881+ otherwise raise CalledProcessError.
882+ """
883+ expression = r's/{0}/{1}/{2}'.format(before,
884+ after, flags)
885+
886+ return subprocess.check_call(["sed", "-i", "-r", "-e",
887+ expression,
888+ os.path.expanduser(filename)])
889diff --git a/hooks/charmhelpers/core/fstab.py b/hooks/charmhelpers/core/fstab.py
890new file mode 100644
891index 0000000..d9fa915
892--- /dev/null
893+++ b/hooks/charmhelpers/core/fstab.py
894@@ -0,0 +1,132 @@
895+#!/usr/bin/env python
896+# -*- coding: utf-8 -*-
897+
898+# Copyright 2014-2015 Canonical Limited.
899+#
900+# Licensed under the Apache License, Version 2.0 (the "License");
901+# you may not use this file except in compliance with the License.
902+# You may obtain a copy of the License at
903+#
904+# http://www.apache.org/licenses/LICENSE-2.0
905+#
906+# Unless required by applicable law or agreed to in writing, software
907+# distributed under the License is distributed on an "AS IS" BASIS,
908+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
909+# See the License for the specific language governing permissions and
910+# limitations under the License.
911+
912+import io
913+import os
914+
915+__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
916+
917+
918+class Fstab(io.FileIO):
919+ """This class extends file in order to implement a file reader/writer
920+ for file `/etc/fstab`
921+ """
922+
923+ class Entry(object):
924+ """Entry class represents a non-comment line on the `/etc/fstab` file
925+ """
926+ def __init__(self, device, mountpoint, filesystem,
927+ options, d=0, p=0):
928+ self.device = device
929+ self.mountpoint = mountpoint
930+ self.filesystem = filesystem
931+
932+ if not options:
933+ options = "defaults"
934+
935+ self.options = options
936+ self.d = int(d)
937+ self.p = int(p)
938+
939+ def __eq__(self, o):
940+ return str(self) == str(o)
941+
942+ def __str__(self):
943+ return "{} {} {} {} {} {}".format(self.device,
944+ self.mountpoint,
945+ self.filesystem,
946+ self.options,
947+ self.d,
948+ self.p)
949+
950+ DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
951+
952+ def __init__(self, path=None):
953+ if path:
954+ self._path = path
955+ else:
956+ self._path = self.DEFAULT_PATH
957+ super(Fstab, self).__init__(self._path, 'rb+')
958+
959+ def _hydrate_entry(self, line):
960+ # NOTE: use split with no arguments to split on any
961+ # whitespace including tabs
962+ return Fstab.Entry(*filter(
963+ lambda x: x not in ('', None),
964+ line.strip("\n").split()))
965+
966+ @property
967+ def entries(self):
968+ self.seek(0)
969+ for line in self.readlines():
970+ line = line.decode('us-ascii')
971+ try:
972+ if line.strip() and not line.strip().startswith("#"):
973+ yield self._hydrate_entry(line)
974+ except ValueError:
975+ pass
976+
977+ def get_entry_by_attr(self, attr, value):
978+ for entry in self.entries:
979+ e_attr = getattr(entry, attr)
980+ if e_attr == value:
981+ return entry
982+ return None
983+
984+ def add_entry(self, entry):
985+ if self.get_entry_by_attr('device', entry.device):
986+ return False
987+
988+ self.write((str(entry) + '\n').encode('us-ascii'))
989+ self.truncate()
990+ return entry
991+
992+ def remove_entry(self, entry):
993+ self.seek(0)
994+
995+ lines = [l.decode('us-ascii') for l in self.readlines()]
996+
997+ found = False
998+ for index, line in enumerate(lines):
999+ if line.strip() and not line.strip().startswith("#"):
1000+ if self._hydrate_entry(line) == entry:
1001+ found = True
1002+ break
1003+
1004+ if not found:
1005+ return False
1006+
1007+ lines.remove(line)
1008+
1009+ self.seek(0)
1010+ self.write(''.join(lines).encode('us-ascii'))
1011+ self.truncate()
1012+ return True
1013+
1014+ @classmethod
1015+ def remove_by_mountpoint(cls, mountpoint, path=None):
1016+ fstab = cls(path=path)
1017+ entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
1018+ if entry:
1019+ return fstab.remove_entry(entry)
1020+ return False
1021+
1022+ @classmethod
1023+ def add(cls, device, mountpoint, filesystem, options=None, path=None):
1024+ return cls(path=path).add_entry(Fstab.Entry(device,
1025+ mountpoint, filesystem,
1026+ options=options))
1027diff --git a/hooks/charmhelpers/core/hookenv.py b/hooks/charmhelpers/core/hookenv.py
1028new file mode 100644
1029index 0000000..e44e22b
1030--- /dev/null
1031+++ b/hooks/charmhelpers/core/hookenv.py
1032@@ -0,0 +1,1068 @@
1033+# Copyright 2014-2015 Canonical Limited.
1034+#
1035+# Licensed under the Apache License, Version 2.0 (the "License");
1036+# you may not use this file except in compliance with the License.
1037+# You may obtain a copy of the License at
1038+#
1039+# http://www.apache.org/licenses/LICENSE-2.0
1040+#
1041+# Unless required by applicable law or agreed to in writing, software
1042+# distributed under the License is distributed on an "AS IS" BASIS,
1043+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1044+# See the License for the specific language governing permissions and
1045+# limitations under the License.
1046+
1047+"Interactions with the Juju environment"
1048+# Copyright 2013 Canonical Ltd.
1049+#
1050+# Authors:
1051+# Charm Helpers Developers <juju@lists.ubuntu.com>
1052+
1053+from __future__ import print_function
1054+import copy
1055+from distutils.version import LooseVersion
1056+from functools import wraps
1057+import glob
1058+import os
1059+import json
1060+import yaml
1061+import subprocess
1062+import sys
1063+import errno
1064+import tempfile
1065+from subprocess import CalledProcessError
1066+
1067+import six
1068+if not six.PY3:
1069+ from UserDict import UserDict
1070+else:
1071+ from collections import UserDict
1072+
1073+CRITICAL = "CRITICAL"
1074+ERROR = "ERROR"
1075+WARNING = "WARNING"
1076+INFO = "INFO"
1077+DEBUG = "DEBUG"
1078+MARKER = object()
1079+
1080+cache = {}
1081+
1082+
1083+def cached(func):
1084+ """Cache return values for multiple executions of func + args
1085+
1086+ For example::
1087+
1088+ @cached
1089+ def unit_get(attribute):
1090+ pass
1091+
1092+ unit_get('test')
1093+
1094+ will cache the result of unit_get + 'test' for future calls.
1095+ """
1096+ @wraps(func)
1097+ def wrapper(*args, **kwargs):
1098+ global cache
1099+ key = str((func, args, kwargs))
1100+ try:
1101+ return cache[key]
1102+ except KeyError:
1103+ pass # Drop out of the exception handler scope.
1104+ res = func(*args, **kwargs)
1105+ cache[key] = res
1106+ return res
1107+ wrapper._wrapped = func
1108+ return wrapper
1109+
1110+
1111+def flush(key):
1112+ """Flushes any entries from function cache where the
1113+ key is found in the function+args """
1114+ flush_list = []
1115+ for item in cache:
1116+ if key in item:
1117+ flush_list.append(item)
1118+ for item in flush_list:
1119+ del cache[item]
1120+
1121+
1122+def log(message, level=None):
1123+ """Write a message to the juju log"""
1124+ command = ['juju-log']
1125+ if level:
1126+ command += ['-l', level]
1127+ if not isinstance(message, six.string_types):
1128+ message = repr(message)
1129+ command += [message]
1130+ # Missing juju-log should not cause failures in unit tests
1131+ # Send log output to stderr
1132+ try:
1133+ subprocess.call(command)
1134+ except OSError as e:
1135+ if e.errno == errno.ENOENT:
1136+ if level:
1137+ message = "{}: {}".format(level, message)
1138+ message = "juju-log: {}".format(message)
1139+ print(message, file=sys.stderr)
1140+ else:
1141+ raise
1142+
1143+
1144+class Serializable(UserDict):
1145+ """Wrapper, an object that can be serialized to yaml or json"""
1146+
1147+ def __init__(self, obj):
1148+ # wrap the object
1149+ UserDict.__init__(self)
1150+ self.data = obj
1151+
1152+ def __getattr__(self, attr):
1153+ # See if this object has attribute.
1154+ if attr in ("json", "yaml", "data"):
1155+ return self.__dict__[attr]
1156+ # Check for attribute in wrapped object.
1157+ got = getattr(self.data, attr, MARKER)
1158+ if got is not MARKER:
1159+ return got
1160+ # Proxy to the wrapped object via dict interface.
1161+ try:
1162+ return self.data[attr]
1163+ except KeyError:
1164+ raise AttributeError(attr)
1165+
1166+ def __getstate__(self):
1167+ # Pickle as a standard dictionary.
1168+ return self.data
1169+
1170+ def __setstate__(self, state):
1171+ # Unpickle into our wrapper.
1172+ self.data = state
1173+
1174+ def json(self):
1175+ """Serialize the object to json"""
1176+ return json.dumps(self.data)
1177+
1178+ def yaml(self):
1179+ """Serialize the object to yaml"""
1180+ return yaml.dump(self.data)
1181+
1182+
1183+def execution_environment():
1184+ """A convenient bundling of the current execution context"""
1185+ context = {}
1186+ context['conf'] = config()
1187+ if relation_id():
1188+ context['reltype'] = relation_type()
1189+ context['relid'] = relation_id()
1190+ context['rel'] = relation_get()
1191+ context['unit'] = local_unit()
1192+ context['rels'] = relations()
1193+ context['env'] = os.environ
1194+ return context
1195+
1196+
1197+def in_relation_hook():
1198+ """Determine whether we're running in a relation hook"""
1199+ return 'JUJU_RELATION' in os.environ
1200+
1201+
1202+def relation_type():
1203+ """The scope for the current relation hook"""
1204+ return os.environ.get('JUJU_RELATION', None)
1205+
1206+
1207+@cached
1208+def relation_id(relation_name=None, service_or_unit=None):
1209+ """The relation ID for the current or a specified relation"""
1210+ if not relation_name and not service_or_unit:
1211+ return os.environ.get('JUJU_RELATION_ID', None)
1212+ elif relation_name and service_or_unit:
1213+ service_name = service_or_unit.split('/')[0]
1214+ for relid in relation_ids(relation_name):
1215+ remote_service = remote_service_name(relid)
1216+ if remote_service == service_name:
1217+ return relid
1218+ else:
1219+ raise ValueError('Must specify neither or both of relation_name and service_or_unit')
1220+
1221+
1222+def local_unit():
1223+ """Local unit ID"""
1224+ return os.environ['JUJU_UNIT_NAME']
1225+
1226+
1227+def remote_unit():
1228+ """The remote unit for the current relation hook"""
1229+ return os.environ.get('JUJU_REMOTE_UNIT', None)
1230+
1231+
1232+def service_name():
1233+ """The name service group this unit belongs to"""
1234+ return local_unit().split('/')[0]
1235+
1236+
1237+@cached
1238+def remote_service_name(relid=None):
1239+ """The remote service name for a given relation-id (or the current relation)"""
1240+ if relid is None:
1241+ unit = remote_unit()
1242+ else:
1243+ units = related_units(relid)
1244+ unit = units[0] if units else None
1245+ return unit.split('/')[0] if unit else None
1246+
1247+
1248+def hook_name():
1249+ """The name of the currently executing hook"""
1250+ return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
1251+
1252+
1253+class Config(dict):
1254+ """A dictionary representation of the charm's config.yaml, with some
1255+ extra features:
1256+
1257+ - See which values in the dictionary have changed since the previous hook.
1258+ - For values that have changed, see what the previous value was.
1259+ - Store arbitrary data for use in a later hook.
1260+
1261+ NOTE: Do not instantiate this object directly - instead call
1262+ ``hookenv.config()``, which will return an instance of :class:`Config`.
1263+
1264+ Example usage::
1265+
1266+ >>> # inside a hook
1267+ >>> from charmhelpers.core import hookenv
1268+ >>> config = hookenv.config()
1269+ >>> config['foo']
1270+ 'bar'
1271+ >>> # store a new key/value for later use
1272+ >>> config['mykey'] = 'myval'
1273+
1274+
1275+ >>> # user runs `juju set mycharm foo=baz`
1276+ >>> # now we're inside subsequent config-changed hook
1277+ >>> config = hookenv.config()
1278+ >>> config['foo']
1279+ 'baz'
1280+ >>> # test to see if this val has changed since last hook
1281+ >>> config.changed('foo')
1282+ True
1283+ >>> # what was the previous value?
1284+ >>> config.previous('foo')
1285+ 'bar'
1286+ >>> # keys/values that we add are preserved across hooks
1287+ >>> config['mykey']
1288+ 'myval'
1289+
1290+ """
1291+ CONFIG_FILE_NAME = '.juju-persistent-config'
1292+
1293+ def __init__(self, *args, **kw):
1294+ super(Config, self).__init__(*args, **kw)
1295+ self.implicit_save = True
1296+ self._prev_dict = None
1297+ self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
1298+ if os.path.exists(self.path):
1299+ self.load_previous()
1300+ atexit(self._implicit_save)
1301+
1302+ def load_previous(self, path=None):
1303+ """Load previous copy of config from disk.
1304+
1305+ In normal usage you don't need to call this method directly - it
1306+ is called automatically at object initialization.
1307+
1308+ :param path:
1309+
1310+ File path from which to load the previous config. If `None`,
1311+ config is loaded from the default location. If `path` is
1312+ specified, subsequent `save()` calls will write to the same
1313+ path.
1314+
1315+ """
1316+ self.path = path or self.path
1317+ with open(self.path) as f:
1318+ self._prev_dict = json.load(f)
1319+ for k, v in copy.deepcopy(self._prev_dict).items():
1320+ if k not in self:
1321+ self[k] = v
1322+
1323+ def changed(self, key):
1324+ """Return True if the current value for this key is different from
1325+ the previous value.
1326+
1327+ """
1328+ if self._prev_dict is None:
1329+ return True
1330+ return self.previous(key) != self.get(key)
1331+
1332+ def previous(self, key):
1333+ """Return previous value for this key, or None if there
1334+ is no previous value.
1335+
1336+ """
1337+ if self._prev_dict:
1338+ return self._prev_dict.get(key)
1339+ return None
1340+
1341+ def save(self):
1342+ """Save this config to disk.
1343+
1344+ If the charm is using the :mod:`Services Framework <services.base>`
1345+ or :meth:'@hook <Hooks.hook>' decorator, this
1346+ is called automatically at the end of successful hook execution.
1347+ Otherwise, it should be called directly by user code.
1348+
1349+ To disable automatic saves, set ``implicit_save=False`` on this
1350+ instance.
1351+
1352+ """
1353+ with open(self.path, 'w') as f:
1354+ json.dump(self, f)
1355+
1356+ def _implicit_save(self):
1357+ if self.implicit_save:
1358+ self.save()
1359+
1360+
1361+@cached
1362+def config(scope=None):
1363+ """Juju charm configuration"""
1364+ config_cmd_line = ['config-get']
1365+ if scope is not None:
1366+ config_cmd_line.append(scope)
1367+ else:
1368+ config_cmd_line.append('--all')
1369+ config_cmd_line.append('--format=json')
1370+ try:
1371+ config_data = json.loads(
1372+ subprocess.check_output(config_cmd_line).decode('UTF-8'))
1373+ if scope is not None:
1374+ return config_data
1375+ return Config(config_data)
1376+ except ValueError:
1377+ return None
1378+
1379+
1380+@cached
1381+def relation_get(attribute=None, unit=None, rid=None):
1382+ """Get relation information"""
1383+ _args = ['relation-get', '--format=json']
1384+ if rid:
1385+ _args.append('-r')
1386+ _args.append(rid)
1387+ _args.append(attribute or '-')
1388+ if unit:
1389+ _args.append(unit)
1390+ try:
1391+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1392+ except ValueError:
1393+ return None
1394+ except CalledProcessError as e:
1395+ if e.returncode == 2:
1396+ return None
1397+ raise
1398+
1399+
1400+def relation_set(relation_id=None, relation_settings=None, **kwargs):
1401+ """Set relation information for the current unit"""
1402+ relation_settings = relation_settings if relation_settings else {}
1403+ relation_cmd_line = ['relation-set']
1404+ accepts_file = "--file" in subprocess.check_output(
1405+ relation_cmd_line + ["--help"], universal_newlines=True)
1406+ if relation_id is not None:
1407+ relation_cmd_line.extend(('-r', relation_id))
1408+ settings = relation_settings.copy()
1409+ settings.update(kwargs)
1410+ for key, value in settings.items():
1411+ # Force value to be a string: it always should, but some call
1412+ # sites pass in things like dicts or numbers.
1413+ if value is not None:
1414+ settings[key] = "{}".format(value)
1415+ if accepts_file:
1416+ # --file was introduced in Juju 1.23.2. Use it by default if
1417+ # available, since otherwise we'll break if the relation data is
1418+ # too big. Ideally we should tell relation-set to read the data from
1419+ # stdin, but that feature is broken in 1.23.2: Bug #1454678.
1420+ with tempfile.NamedTemporaryFile(delete=False) as settings_file:
1421+ settings_file.write(yaml.safe_dump(settings).encode("utf-8"))
1422+ subprocess.check_call(
1423+ relation_cmd_line + ["--file", settings_file.name])
1424+ os.remove(settings_file.name)
1425+ else:
1426+ for key, value in settings.items():
1427+ if value is None:
1428+ relation_cmd_line.append('{}='.format(key))
1429+ else:
1430+ relation_cmd_line.append('{}={}'.format(key, value))
1431+ subprocess.check_call(relation_cmd_line)
1432+ # Flush cache of any relation-gets for local unit
1433+ flush(local_unit())
1434+
1435+
1436+def relation_clear(r_id=None):
1437+ ''' Clears any relation data already set on relation r_id '''
1438+ settings = relation_get(rid=r_id,
1439+ unit=local_unit())
1440+ for setting in settings:
1441+ if setting not in ['public-address', 'private-address']:
1442+ settings[setting] = None
1443+ relation_set(relation_id=r_id,
1444+ **settings)
1445+
1446+
1447+@cached
1448+def relation_ids(reltype=None):
1449+ """A list of relation_ids"""
1450+ reltype = reltype or relation_type()
1451+ relid_cmd_line = ['relation-ids', '--format=json']
1452+ if reltype is not None:
1453+ relid_cmd_line.append(reltype)
1454+ return json.loads(
1455+ subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
1456+ return []
1457+
1458+
1459+@cached
1460+def related_units(relid=None):
1461+ """A list of related units"""
1462+ relid = relid or relation_id()
1463+ units_cmd_line = ['relation-list', '--format=json']
1464+ if relid is not None:
1465+ units_cmd_line.extend(('-r', relid))
1466+ return json.loads(
1467+ subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
1468+
1469+
1470+@cached
1471+def relation_for_unit(unit=None, rid=None):
1472+ """Get the json represenation of a unit's relation"""
1473+ unit = unit or remote_unit()
1474+ relation = relation_get(unit=unit, rid=rid)
1475+ for key in relation:
1476+ if key.endswith('-list'):
1477+ relation[key] = relation[key].split()
1478+ relation['__unit__'] = unit
1479+ return relation
1480+
1481+
1482+@cached
1483+def relations_for_id(relid=None):
1484+ """Get relations of a specific relation ID"""
1485+ relation_data = []
1486+ relid = relid or relation_ids()
1487+ for unit in related_units(relid):
1488+ unit_data = relation_for_unit(unit, relid)
1489+ unit_data['__relid__'] = relid
1490+ relation_data.append(unit_data)
1491+ return relation_data
1492+
1493+
1494+@cached
1495+def relations_of_type(reltype=None):
1496+ """Get relations of a specific type"""
1497+ relation_data = []
1498+ reltype = reltype or relation_type()
1499+ for relid in relation_ids(reltype):
1500+ for relation in relations_for_id(relid):
1501+ relation['__relid__'] = relid
1502+ relation_data.append(relation)
1503+ return relation_data
1504+
1505+
1506+@cached
1507+def metadata():
1508+ """Get the current charm metadata.yaml contents as a python object"""
1509+ with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
1510+ return yaml.safe_load(md)
1511+
1512+
1513+@cached
1514+def relation_types():
1515+ """Get a list of relation types supported by this charm"""
1516+ rel_types = []
1517+ md = metadata()
1518+ for key in ('provides', 'requires', 'peers'):
1519+ section = md.get(key)
1520+ if section:
1521+ rel_types.extend(section.keys())
1522+ return rel_types
1523+
1524+
1525+@cached
1526+def peer_relation_id():
1527+ '''Get the peers relation id if a peers relation has been joined, else None.'''
1528+ md = metadata()
1529+ section = md.get('peers')
1530+ if section:
1531+ for key in section:
1532+ relids = relation_ids(key)
1533+ if relids:
1534+ return relids[0]
1535+ return None
1536+
1537+
1538+@cached
1539+def relation_to_interface(relation_name):
1540+ """
1541+ Given the name of a relation, return the interface that relation uses.
1542+
1543+ :returns: The interface name, or ``None``.
1544+ """
1545+ return relation_to_role_and_interface(relation_name)[1]
1546+
1547+
1548+@cached
1549+def relation_to_role_and_interface(relation_name):
1550+ """
1551+ Given the name of a relation, return the role and the name of the interface
1552+ that relation uses (where role is one of ``provides``, ``requires``, or ``peers``).
1553+
1554+ :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
1555+ """
1556+ _metadata = metadata()
1557+ for role in ('provides', 'requires', 'peers'):
1558+ interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
1559+ if interface:
1560+ return role, interface
1561+ return None, None
1562+
1563+
1564+@cached
1565+def role_and_interface_to_relations(role, interface_name):
1566+ """
1567+ Given a role and interface name, return a list of relation names for the
1568+ current charm that use that interface under that role (where role is one
1569+ of ``provides``, ``requires``, or ``peers``).
1570+
1571+ :returns: A list of relation names.
1572+ """
1573+ _metadata = metadata()
1574+ results = []
1575+ for relation_name, relation in _metadata.get(role, {}).items():
1576+ if relation['interface'] == interface_name:
1577+ results.append(relation_name)
1578+ return results
1579+
1580+
1581+@cached
1582+def interface_to_relations(interface_name):
1583+ """
1584+ Given an interface, return a list of relation names for the current
1585+ charm that use that interface.
1586+
1587+ :returns: A list of relation names.
1588+ """
1589+ results = []
1590+ for role in ('provides', 'requires', 'peers'):
1591+ results.extend(role_and_interface_to_relations(role, interface_name))
1592+ return results
1593+
1594+
1595+@cached
1596+def charm_name():
1597+ """Get the name of the current charm as is specified on metadata.yaml"""
1598+ return metadata().get('name')
1599+
1600+
1601+@cached
1602+def relations():
1603+ """Get a nested dictionary of relation data for all related units"""
1604+ rels = {}
1605+ for reltype in relation_types():
1606+ relids = {}
1607+ for relid in relation_ids(reltype):
1608+ units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
1609+ for unit in related_units(relid):
1610+ reldata = relation_get(unit=unit, rid=relid)
1611+ units[unit] = reldata
1612+ relids[relid] = units
1613+ rels[reltype] = relids
1614+ return rels
1615+
1616+
1617+@cached
1618+def is_relation_made(relation, keys='private-address'):
1619+ '''
1620+ Determine whether a relation is established by checking for
1621+ presence of key(s). If a list of keys is provided, they
1622+ must all be present for the relation to be identified as made
1623+ '''
1624+ if isinstance(keys, str):
1625+ keys = [keys]
1626+ for r_id in relation_ids(relation):
1627+ for unit in related_units(r_id):
1628+ context = {}
1629+ for k in keys:
1630+ context[k] = relation_get(k, rid=r_id,
1631+ unit=unit)
1632+ if None not in context.values():
1633+ return True
1634+ return False
1635+
1636+
1637+def open_port(port, protocol="TCP"):
1638+ """Open a service network port"""
1639+ _args = ['open-port']
1640+ _args.append('{}/{}'.format(port, protocol))
1641+ subprocess.check_call(_args)
1642+
1643+
1644+def close_port(port, protocol="TCP"):
1645+ """Close a service network port"""
1646+ _args = ['close-port']
1647+ _args.append('{}/{}'.format(port, protocol))
1648+ subprocess.check_call(_args)
1649+
1650+
1651+def open_ports(start, end, protocol="TCP"):
1652+ """Opens a range of service network ports"""
1653+ _args = ['open-port']
1654+ _args.append('{}-{}/{}'.format(start, end, protocol))
1655+ subprocess.check_call(_args)
1656+
1657+
1658+def close_ports(start, end, protocol="TCP"):
1659+ """Close a range of service network ports"""
1660+ _args = ['close-port']
1661+ _args.append('{}-{}/{}'.format(start, end, protocol))
1662+ subprocess.check_call(_args)
1663+
1664+
1665+@cached
1666+def unit_get(attribute):
1667+ """Get the unit ID for the remote unit"""
1668+ _args = ['unit-get', '--format=json', attribute]
1669+ try:
1670+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1671+ except ValueError:
1672+ return None
1673+
1674+
1675+def unit_public_ip():
1676+ """Get this unit's public IP address"""
1677+ return unit_get('public-address')
1678+
1679+
1680+def unit_private_ip():
1681+ """Get this unit's private IP address"""
1682+ return unit_get('private-address')
1683+
1684+
1685+@cached
1686+def storage_get(attribute=None, storage_id=None):
1687+ """Get storage attributes"""
1688+ _args = ['storage-get', '--format=json']
1689+ if storage_id:
1690+ _args.extend(('-s', storage_id))
1691+ if attribute:
1692+ _args.append(attribute)
1693+ try:
1694+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1695+ except ValueError:
1696+ return None
1697+
1698+
1699+@cached
1700+def storage_list(storage_name=None):
1701+ """List the storage IDs for the unit"""
1702+ _args = ['storage-list', '--format=json']
1703+ if storage_name:
1704+ _args.append(storage_name)
1705+ try:
1706+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1707+ except ValueError:
1708+ return None
1709+ except OSError as e:
1710+ import errno
1711+ if e.errno == errno.ENOENT:
1712+ # storage-list does not exist
1713+ return []
1714+ raise
1715+
1716+
1717+class UnregisteredHookError(Exception):
1718+ """Raised when an undefined hook is called"""
1719+ pass
1720+
1721+
1722+class Hooks(object):
1723+ """A convenient handler for hook functions.
1724+
1725+ Example::
1726+
1727+ hooks = Hooks()
1728+
1729+ # register a hook, taking its name from the function name
1730+ @hooks.hook()
1731+ def install():
1732+ pass # your code here
1733+
1734+ # register a hook, providing a custom hook name
1735+ @hooks.hook("config-changed")
1736+ def config_changed():
1737+ pass # your code here
1738+
1739+ if __name__ == "__main__":
1740+ # execute a hook based on the name the program is called by
1741+ hooks.execute(sys.argv)
1742+ """
1743+
1744+ def __init__(self, config_save=None):
1745+ super(Hooks, self).__init__()
1746+ self._hooks = {}
1747+
1748+ # For unknown reasons, we allow the Hooks constructor to override
1749+ # config().implicit_save.
1750+ if config_save is not None:
1751+ config().implicit_save = config_save
1752+
1753+ def register(self, name, function):
1754+ """Register a hook"""
1755+ self._hooks[name] = function
1756+
1757+ def execute(self, args):
1758+ """Execute a registered hook based on args[0]"""
1759+ _run_atstart()
1760+ hook_name = os.path.basename(args[0])
1761+ if hook_name in self._hooks:
1762+ try:
1763+ self._hooks[hook_name]()
1764+ except SystemExit as x:
1765+ if x.code is None or x.code == 0:
1766+ _run_atexit()
1767+ raise
1768+ _run_atexit()
1769+ else:
1770+ raise UnregisteredHookError(hook_name)
1771+
1772+ def hook(self, *hook_names):
1773+ """Decorator, registering them as hooks"""
1774+ def wrapper(decorated):
1775+ for hook_name in hook_names:
1776+ self.register(hook_name, decorated)
1777+ else:
1778+ self.register(decorated.__name__, decorated)
1779+ if '_' in decorated.__name__:
1780+ self.register(
1781+ decorated.__name__.replace('_', '-'), decorated)
1782+ return decorated
1783+ return wrapper
1784+
1785+
1786+def charm_dir():
1787+ """Return the root directory of the current charm"""
1788+ return os.environ.get('CHARM_DIR')
1789+
1790+
1791+@cached
1792+def action_get(key=None):
1793+ """Gets the value of an action parameter, or all key/value param pairs"""
1794+ cmd = ['action-get']
1795+ if key is not None:
1796+ cmd.append(key)
1797+ cmd.append('--format=json')
1798+ action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
1799+ return action_data
1800+
1801+
1802+def action_set(values):
1803+ """Sets the values to be returned after the action finishes"""
1804+ cmd = ['action-set']
1805+ for k, v in list(values.items()):
1806+ cmd.append('{}={}'.format(k, v))
1807+ subprocess.check_call(cmd)
1808+
1809+
1810+def action_fail(message):
1811+ """Sets the action status to failed and sets the error message.
1812+
1813+ The results set by action_set are preserved."""
1814+ subprocess.check_call(['action-fail', message])
1815+
1816+
1817+def action_name():
1818+ """Get the name of the currently executing action."""
1819+ return os.environ.get('JUJU_ACTION_NAME')
1820+
1821+
1822+def action_uuid():
1823+ """Get the UUID of the currently executing action."""
1824+ return os.environ.get('JUJU_ACTION_UUID')
1825+
1826+
1827+def action_tag():
1828+ """Get the tag for the currently executing action."""
1829+ return os.environ.get('JUJU_ACTION_TAG')
1830+
1831+
1832+def status_set(workload_state, message):
1833+ """Set the workload state with a message
1834+
1835+ Use status-set to set the workload state with a message which is visible
1836+ to the user via juju status. If the status-set command is not found then
1837+ assume this is juju < 1.23 and juju-log the message unstead.
1838+
1839+ workload_state -- valid juju workload state.
1840+ message -- status update message
1841+ """
1842+ valid_states = ['maintenance', 'blocked', 'waiting', 'active']
1843+ if workload_state not in valid_states:
1844+ raise ValueError(
1845+ '{!r} is not a valid workload state'.format(workload_state)
1846+ )
1847+ cmd = ['status-set', workload_state, message]
1848+ try:
1849+ ret = subprocess.call(cmd)
1850+ if ret == 0:
1851+ return
1852+ except OSError as e:
1853+ if e.errno != errno.ENOENT:
1854+ raise
1855+ log_message = 'status-set failed: {} {}'.format(workload_state,
1856+ message)
1857+ log(log_message, level='INFO')
1858+
1859+
1860+def status_get():
1861+ """Retrieve the previously set juju workload state and message
1862+
1863+ If the status-get command is not found then assume this is juju < 1.23 and
1864+ return 'unknown', ""
1865+
1866+ """
1867+ cmd = ['status-get', "--format=json", "--include-data"]
1868+ try:
1869+ raw_status = subprocess.check_output(cmd)
1870+ except OSError as e:
1871+ if e.errno == errno.ENOENT:
1872+ return ('unknown', "")
1873+ else:
1874+ raise
1875+ else:
1876+ status = json.loads(raw_status.decode("UTF-8"))
1877+ return (status["status"], status["message"])
1878+
1879+
1880+def translate_exc(from_exc, to_exc):
1881+ def inner_translate_exc1(f):
1882+ @wraps(f)
1883+ def inner_translate_exc2(*args, **kwargs):
1884+ try:
1885+ return f(*args, **kwargs)
1886+ except from_exc:
1887+ raise to_exc
1888+
1889+ return inner_translate_exc2
1890+
1891+ return inner_translate_exc1
1892+
1893+
1894+def application_version_set(version):
1895+ """Charm authors may trigger this command from any hook to output what
1896+ version of the application is running. This could be a package version,
1897+ for instance postgres version 9.5. It could also be a build number or
1898+ version control revision identifier, for instance git sha 6fb7ba68. """
1899+
1900+ cmd = ['application-version-set']
1901+ cmd.append(version)
1902+ try:
1903+ subprocess.check_call(cmd)
1904+ except OSError:
1905+ log("Application Version: {}".format(version))
1906+
1907+
1908+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1909+def is_leader():
1910+ """Does the current unit hold the juju leadership
1911+
1912+ Uses juju to determine whether the current unit is the leader of its peers
1913+ """
1914+ cmd = ['is-leader', '--format=json']
1915+ return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
1916+
1917+
1918+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1919+def leader_get(attribute=None):
1920+ """Juju leader get value(s)"""
1921+ cmd = ['leader-get', '--format=json'] + [attribute or '-']
1922+ return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
1923+
1924+
1925+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1926+def leader_set(settings=None, **kwargs):
1927+ """Juju leader set value(s)"""
1928+ # Don't log secrets.
1929+ # log("Juju leader-set '%s'" % (settings), level=DEBUG)
1930+ cmd = ['leader-set']
1931+ settings = settings or {}
1932+ settings.update(kwargs)
1933+ for k, v in settings.items():
1934+ if v is None:
1935+ cmd.append('{}='.format(k))
1936+ else:
1937+ cmd.append('{}={}'.format(k, v))
1938+ subprocess.check_call(cmd)
1939+
1940+
1941+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1942+def payload_register(ptype, klass, pid):
1943+ """ is used while a hook is running to let Juju know that a
1944+ payload has been started."""
1945+ cmd = ['payload-register']
1946+ for x in [ptype, klass, pid]:
1947+ cmd.append(x)
1948+ subprocess.check_call(cmd)
1949+
1950+
1951+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1952+def payload_unregister(klass, pid):
1953+ """ is used while a hook is running to let Juju know
1954+ that a payload has been manually stopped. The <class> and <id> provided
1955+ must match a payload that has been previously registered with juju using
1956+ payload-register."""
1957+ cmd = ['payload-unregister']
1958+ for x in [klass, pid]:
1959+ cmd.append(x)
1960+ subprocess.check_call(cmd)
1961+
1962+
1963+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1964+def payload_status_set(klass, pid, status):
1965+ """is used to update the current status of a registered payload.
1966+ The <class> and <id> provided must match a payload that has been previously
1967+ registered with juju using payload-register. The <status> must be one of the
1968+ follow: starting, started, stopping, stopped"""
1969+ cmd = ['payload-status-set']
1970+ for x in [klass, pid, status]:
1971+ cmd.append(x)
1972+ subprocess.check_call(cmd)
1973+
1974+
1975+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1976+def resource_get(name):
1977+ """used to fetch the resource path of the given name.
1978+
1979+ <name> must match a name of defined resource in metadata.yaml
1980+
1981+ returns either a path or False if resource not available
1982+ """
1983+ if not name:
1984+ return False
1985+
1986+ cmd = ['resource-get', name]
1987+ try:
1988+ return subprocess.check_output(cmd).decode('UTF-8')
1989+ except subprocess.CalledProcessError:
1990+ return False
1991+
1992+
1993+@cached
1994+def juju_version():
1995+ """Full version string (eg. '1.23.3.1-trusty-amd64')"""
1996+ # Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
1997+ jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
1998+ return subprocess.check_output([jujud, 'version'],
1999+ universal_newlines=True).strip()
2000+
2001+
2002+@cached
2003+def has_juju_version(minimum_version):
2004+ """Return True if the Juju version is at least the provided version"""
2005+ return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
2006+
2007+
2008+_atexit = []
2009+_atstart = []
2010+
2011+
2012+def atstart(callback, *args, **kwargs):
2013+ '''Schedule a callback to run before the main hook.
2014+
2015+ Callbacks are run in the order they were added.
2016+
2017+ This is useful for modules and classes to perform initialization
2018+ and inject behavior. In particular:
2019+
2020+ - Run common code before all of your hooks, such as logging
2021+ the hook name or interesting relation data.
2022+ - Defer object or module initialization that requires a hook
2023+ context until we know there actually is a hook context,
2024+ making testing easier.
2025+ - Rather than requiring charm authors to include boilerplate to
2026+ invoke your helper's behavior, have it run automatically if
2027+ your object is instantiated or module imported.
2028+
2029+ This is not at all useful after your hook framework as been launched.
2030+ '''
2031+ global _atstart
2032+ _atstart.append((callback, args, kwargs))
2033+
2034+
2035+def atexit(callback, *args, **kwargs):
2036+ '''Schedule a callback to run on successful hook completion.
2037+
2038+ Callbacks are run in the reverse order that they were added.'''
2039+ _atexit.append((callback, args, kwargs))
2040+
2041+
2042+def _run_atstart():
2043+ '''Hook frameworks must invoke this before running the main hook body.'''
2044+ global _atstart
2045+ for callback, args, kwargs in _atstart:
2046+ callback(*args, **kwargs)
2047+ del _atstart[:]
2048+
2049+
2050+def _run_atexit():
2051+ '''Hook frameworks must invoke this after the main hook body has
2052+ successfully completed. Do not invoke it if the hook fails.'''
2053+ global _atexit
2054+ for callback, args, kwargs in reversed(_atexit):
2055+ callback(*args, **kwargs)
2056+ del _atexit[:]
2057+
2058+
2059+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
2060+def network_get_primary_address(binding):
2061+ '''
2062+ Retrieve the primary network address for a named binding
2063+
2064+ :param binding: string. The name of a relation of extra-binding
2065+ :return: string. The primary IP address for the named binding
2066+ :raise: NotImplementedError if run on Juju < 2.0
2067+ '''
2068+ cmd = ['network-get', '--primary-address', binding]
2069+ return subprocess.check_output(cmd).decode('UTF-8').strip()
2070+
2071+
2072+def add_metric(*args, **kwargs):
2073+ """Add metric values. Values may be expressed with keyword arguments. For
2074+ metric names containing dashes, these may be expressed as one or more
2075+ 'key=value' positional arguments. May only be called from the collect-metrics
2076+ hook."""
2077+ _args = ['add-metric']
2078+ _kvpairs = []
2079+ _kvpairs.extend(args)
2080+ _kvpairs.extend(['{}={}'.format(k, v) for k, v in kwargs.items()])
2081+ _args.extend(sorted(_kvpairs))
2082+ try:
2083+ subprocess.check_call(_args)
2084+ return
2085+ except EnvironmentError as e:
2086+ if e.errno != errno.ENOENT:
2087+ raise
2088+ log_message = 'add-metric failed: {}'.format(' '.join(_kvpairs))
2089+ log(log_message, level='INFO')
2090+
2091+
2092+def meter_status():
2093+ """Get the meter status, if running in the meter-status-changed hook."""
2094+ return os.environ.get('JUJU_METER_STATUS')
2095+
2096+
2097+def meter_info():
2098+ """Get the meter status information, if running in the meter-status-changed
2099+ hook."""
2100+ return os.environ.get('JUJU_METER_INFO')
2101diff --git a/hooks/charmhelpers/core/host.py b/hooks/charmhelpers/core/host.py
2102new file mode 100644
2103index 0000000..88e80a4
2104--- /dev/null
2105+++ b/hooks/charmhelpers/core/host.py
2106@@ -0,0 +1,922 @@
2107+# Copyright 2014-2015 Canonical Limited.
2108+#
2109+# Licensed under the Apache License, Version 2.0 (the "License");
2110+# you may not use this file except in compliance with the License.
2111+# You may obtain a copy of the License at
2112+#
2113+# http://www.apache.org/licenses/LICENSE-2.0
2114+#
2115+# Unless required by applicable law or agreed to in writing, software
2116+# distributed under the License is distributed on an "AS IS" BASIS,
2117+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2118+# See the License for the specific language governing permissions and
2119+# limitations under the License.
2120+
2121+"""Tools for working with the host system"""
2122+# Copyright 2012 Canonical Ltd.
2123+#
2124+# Authors:
2125+# Nick Moffitt <nick.moffitt@canonical.com>
2126+# Matthew Wedgwood <matthew.wedgwood@canonical.com>
2127+
2128+import os
2129+import re
2130+import pwd
2131+import glob
2132+import grp
2133+import random
2134+import string
2135+import subprocess
2136+import hashlib
2137+import functools
2138+import itertools
2139+import six
2140+
2141+from contextlib import contextmanager
2142+from collections import OrderedDict
2143+from .hookenv import log
2144+from .fstab import Fstab
2145+from charmhelpers.osplatform import get_platform
2146+
2147+__platform__ = get_platform()
2148+if __platform__ == "ubuntu":
2149+ from charmhelpers.core.host_factory.ubuntu import (
2150+ service_available,
2151+ add_new_group,
2152+ lsb_release,
2153+ cmp_pkgrevno,
2154+ CompareHostReleases,
2155+ ) # flake8: noqa -- ignore F401 for this import
2156+elif __platform__ == "centos":
2157+ from charmhelpers.core.host_factory.centos import (
2158+ service_available,
2159+ add_new_group,
2160+ lsb_release,
2161+ cmp_pkgrevno,
2162+ CompareHostReleases,
2163+ ) # flake8: noqa -- ignore F401 for this import
2164+
2165+UPDATEDB_PATH = '/etc/updatedb.conf'
2166+
2167+def service_start(service_name, **kwargs):
2168+ """Start a system service.
2169+
2170+ The specified service name is managed via the system level init system.
2171+ Some init systems (e.g. upstart) require that additional arguments be
2172+ provided in order to directly control service instances whereas other init
2173+ systems allow for addressing instances of a service directly by name (e.g.
2174+ systemd).
2175+
2176+ The kwargs allow for the additional parameters to be passed to underlying
2177+ init systems for those systems which require/allow for them. For example,
2178+ the ceph-osd upstart script requires the id parameter to be passed along
2179+ in order to identify which running daemon should be reloaded. The follow-
2180+ ing example stops the ceph-osd service for instance id=4:
2181+
2182+ service_stop('ceph-osd', id=4)
2183+
2184+ :param service_name: the name of the service to stop
2185+ :param **kwargs: additional parameters to pass to the init system when
2186+ managing services. These will be passed as key=value
2187+ parameters to the init system's commandline. kwargs
2188+ are ignored for systemd enabled systems.
2189+ """
2190+ return service('start', service_name, **kwargs)
2191+
2192+
2193+def service_stop(service_name, **kwargs):
2194+ """Stop a system service.
2195+
2196+ The specified service name is managed via the system level init system.
2197+ Some init systems (e.g. upstart) require that additional arguments be
2198+ provided in order to directly control service instances whereas other init
2199+ systems allow for addressing instances of a service directly by name (e.g.
2200+ systemd).
2201+
2202+ The kwargs allow for the additional parameters to be passed to underlying
2203+ init systems for those systems which require/allow for them. For example,
2204+ the ceph-osd upstart script requires the id parameter to be passed along
2205+ in order to identify which running daemon should be reloaded. The follow-
2206+ ing example stops the ceph-osd service for instance id=4:
2207+
2208+ service_stop('ceph-osd', id=4)
2209+
2210+ :param service_name: the name of the service to stop
2211+ :param **kwargs: additional parameters to pass to the init system when
2212+ managing services. These will be passed as key=value
2213+ parameters to the init system's commandline. kwargs
2214+ are ignored for systemd enabled systems.
2215+ """
2216+ return service('stop', service_name, **kwargs)
2217+
2218+
2219+def service_restart(service_name, **kwargs):
2220+ """Restart a system service.
2221+
2222+ The specified service name is managed via the system level init system.
2223+ Some init systems (e.g. upstart) require that additional arguments be
2224+ provided in order to directly control service instances whereas other init
2225+ systems allow for addressing instances of a service directly by name (e.g.
2226+ systemd).
2227+
2228+ The kwargs allow for the additional parameters to be passed to underlying
2229+ init systems for those systems which require/allow for them. For example,
2230+ the ceph-osd upstart script requires the id parameter to be passed along
2231+ in order to identify which running daemon should be restarted. The follow-
2232+ ing example restarts the ceph-osd service for instance id=4:
2233+
2234+ service_restart('ceph-osd', id=4)
2235+
2236+ :param service_name: the name of the service to restart
2237+ :param **kwargs: additional parameters to pass to the init system when
2238+ managing services. These will be passed as key=value
2239+ parameters to the init system's commandline. kwargs
2240+ are ignored for init systems not allowing additional
2241+ parameters via the commandline (systemd).
2242+ """
2243+ return service('restart', service_name)
2244+
2245+
2246+def service_reload(service_name, restart_on_failure=False, **kwargs):
2247+ """Reload a system service, optionally falling back to restart if
2248+ reload fails.
2249+
2250+ The specified service name is managed via the system level init system.
2251+ Some init systems (e.g. upstart) require that additional arguments be
2252+ provided in order to directly control service instances whereas other init
2253+ systems allow for addressing instances of a service directly by name (e.g.
2254+ systemd).
2255+
2256+ The kwargs allow for the additional parameters to be passed to underlying
2257+ init systems for those systems which require/allow for them. For example,
2258+ the ceph-osd upstart script requires the id parameter to be passed along
2259+ in order to identify which running daemon should be reloaded. The follow-
2260+ ing example restarts the ceph-osd service for instance id=4:
2261+
2262+ service_reload('ceph-osd', id=4)
2263+
2264+ :param service_name: the name of the service to reload
2265+ :param restart_on_failure: boolean indicating whether to fallback to a
2266+ restart if the reload fails.
2267+ :param **kwargs: additional parameters to pass to the init system when
2268+ managing services. These will be passed as key=value
2269+ parameters to the init system's commandline. kwargs
2270+ are ignored for init systems not allowing additional
2271+ parameters via the commandline (systemd).
2272+ """
2273+ service_result = service('reload', service_name, **kwargs)
2274+ if not service_result and restart_on_failure:
2275+ service_result = service('restart', service_name, **kwargs)
2276+ return service_result
2277+
2278+
2279+def service_pause(service_name, init_dir="/etc/init", initd_dir="/etc/init.d",
2280+ **kwargs):
2281+ """Pause a system service.
2282+
2283+ Stop it, and prevent it from starting again at boot.
2284+
2285+ :param service_name: the name of the service to pause
2286+ :param init_dir: path to the upstart init directory
2287+ :param initd_dir: path to the sysv init directory
2288+ :param **kwargs: additional parameters to pass to the init system when
2289+ managing services. These will be passed as key=value
2290+ parameters to the init system's commandline. kwargs
2291+ are ignored for init systems which do not support
2292+ key=value arguments via the commandline.
2293+ """
2294+ stopped = True
2295+ if service_running(service_name, **kwargs):
2296+ stopped = service_stop(service_name, **kwargs)
2297+ upstart_file = os.path.join(init_dir, "{}.conf".format(service_name))
2298+ sysv_file = os.path.join(initd_dir, service_name)
2299+ if init_is_systemd():
2300+ service('mask', service_name)
2301+ elif os.path.exists(upstart_file):
2302+ override_path = os.path.join(
2303+ init_dir, '{}.override'.format(service_name))
2304+ with open(override_path, 'w') as fh:
2305+ fh.write("manual\n")
2306+ elif os.path.exists(sysv_file):
2307+ subprocess.check_call(["update-rc.d", service_name, "disable"])
2308+ else:
2309+ raise ValueError(
2310+ "Unable to detect {0} as SystemD, Upstart {1} or"
2311+ " SysV {2}".format(
2312+ service_name, upstart_file, sysv_file))
2313+ return stopped
2314+
2315+
2316+def service_resume(service_name, init_dir="/etc/init",
2317+ initd_dir="/etc/init.d", **kwargs):
2318+ """Resume a system service.
2319+
2320+ Reenable starting again at boot. Start the service.
2321+
2322+ :param service_name: the name of the service to resume
2323+ :param init_dir: the path to the init dir
2324+ :param initd dir: the path to the initd dir
2325+ :param **kwargs: additional parameters to pass to the init system when
2326+ managing services. These will be passed as key=value
2327+ parameters to the init system's commandline. kwargs
2328+ are ignored for systemd enabled systems.
2329+ """
2330+ upstart_file = os.path.join(init_dir, "{}.conf".format(service_name))
2331+ sysv_file = os.path.join(initd_dir, service_name)
2332+ if init_is_systemd():
2333+ service('unmask', service_name)
2334+ elif os.path.exists(upstart_file):
2335+ override_path = os.path.join(
2336+ init_dir, '{}.override'.format(service_name))
2337+ if os.path.exists(override_path):
2338+ os.unlink(override_path)
2339+ elif os.path.exists(sysv_file):
2340+ subprocess.check_call(["update-rc.d", service_name, "enable"])
2341+ else:
2342+ raise ValueError(
2343+ "Unable to detect {0} as SystemD, Upstart {1} or"
2344+ " SysV {2}".format(
2345+ service_name, upstart_file, sysv_file))
2346+ started = service_running(service_name, **kwargs)
2347+
2348+ if not started:
2349+ started = service_start(service_name, **kwargs)
2350+ return started
2351+
2352+
2353+def service(action, service_name, **kwargs):
2354+ """Control a system service.
2355+
2356+ :param action: the action to take on the service
2357+ :param service_name: the name of the service to perform th action on
2358+ :param **kwargs: additional params to be passed to the service command in
2359+ the form of key=value.
2360+ """
2361+ if init_is_systemd():
2362+ cmd = ['systemctl', action, service_name]
2363+ else:
2364+ cmd = ['service', service_name, action]
2365+ for key, value in six.iteritems(kwargs):
2366+ parameter = '%s=%s' % (key, value)
2367+ cmd.append(parameter)
2368+ return subprocess.call(cmd) == 0
2369+
2370+
2371+_UPSTART_CONF = "/etc/init/{}.conf"
2372+_INIT_D_CONF = "/etc/init.d/{}"
2373+
2374+
2375+def service_running(service_name, **kwargs):
2376+ """Determine whether a system service is running.
2377+
2378+ :param service_name: the name of the service
2379+ :param **kwargs: additional args to pass to the service command. This is
2380+ used to pass additional key=value arguments to the
2381+ service command line for managing specific instance
2382+ units (e.g. service ceph-osd status id=2). The kwargs
2383+ are ignored in systemd services.
2384+ """
2385+ if init_is_systemd():
2386+ return service('is-active', service_name)
2387+ else:
2388+ if os.path.exists(_UPSTART_CONF.format(service_name)):
2389+ try:
2390+ cmd = ['status', service_name]
2391+ for key, value in six.iteritems(kwargs):
2392+ parameter = '%s=%s' % (key, value)
2393+ cmd.append(parameter)
2394+ output = subprocess.check_output(cmd,
2395+ stderr=subprocess.STDOUT).decode('UTF-8')
2396+ except subprocess.CalledProcessError:
2397+ return False
2398+ else:
2399+ # This works for upstart scripts where the 'service' command
2400+ # returns a consistent string to represent running
2401+ # 'start/running'
2402+ if ("start/running" in output or
2403+ "is running" in output or
2404+ "up and running" in output):
2405+ return True
2406+ elif os.path.exists(_INIT_D_CONF.format(service_name)):
2407+ # Check System V scripts init script return codes
2408+ return service('status', service_name)
2409+ return False
2410+
2411+
2412+SYSTEMD_SYSTEM = '/run/systemd/system'
2413+
2414+
2415+def init_is_systemd():
2416+ """Return True if the host system uses systemd, False otherwise."""
2417+ if lsb_release()['DISTRIB_CODENAME'] == 'trusty':
2418+ return False
2419+ return os.path.isdir(SYSTEMD_SYSTEM)
2420+
2421+
2422+def adduser(username, password=None, shell='/bin/bash',
2423+ system_user=False, primary_group=None,
2424+ secondary_groups=None, uid=None, home_dir=None):
2425+ """Add a user to the system.
2426+
2427+ Will log but otherwise succeed if the user already exists.
2428+
2429+ :param str username: Username to create
2430+ :param str password: Password for user; if ``None``, create a system user
2431+ :param str shell: The default shell for the user
2432+ :param bool system_user: Whether to create a login or system user
2433+ :param str primary_group: Primary group for user; defaults to username
2434+ :param list secondary_groups: Optional list of additional groups
2435+ :param int uid: UID for user being created
2436+ :param str home_dir: Home directory for user
2437+
2438+ :returns: The password database entry struct, as returned by `pwd.getpwnam`
2439+ """
2440+ try:
2441+ user_info = pwd.getpwnam(username)
2442+ log('user {0} already exists!'.format(username))
2443+ if uid:
2444+ user_info = pwd.getpwuid(int(uid))
2445+ log('user with uid {0} already exists!'.format(uid))
2446+ except KeyError:
2447+ log('creating user {0}'.format(username))
2448+ cmd = ['useradd']
2449+ if uid:
2450+ cmd.extend(['--uid', str(uid)])
2451+ if home_dir:
2452+ cmd.extend(['--home', str(home_dir)])
2453+ if system_user or password is None:
2454+ cmd.append('--system')
2455+ else:
2456+ cmd.extend([
2457+ '--create-home',
2458+ '--shell', shell,
2459+ '--password', password,
2460+ ])
2461+ if not primary_group:
2462+ try:
2463+ grp.getgrnam(username)
2464+ primary_group = username # avoid "group exists" error
2465+ except KeyError:
2466+ pass
2467+ if primary_group:
2468+ cmd.extend(['-g', primary_group])
2469+ if secondary_groups:
2470+ cmd.extend(['-G', ','.join(secondary_groups)])
2471+ cmd.append(username)
2472+ subprocess.check_call(cmd)
2473+ user_info = pwd.getpwnam(username)
2474+ return user_info
2475+
2476+
2477+def user_exists(username):
2478+ """Check if a user exists"""
2479+ try:
2480+ pwd.getpwnam(username)
2481+ user_exists = True
2482+ except KeyError:
2483+ user_exists = False
2484+ return user_exists
2485+
2486+
2487+def uid_exists(uid):
2488+ """Check if a uid exists"""
2489+ try:
2490+ pwd.getpwuid(uid)
2491+ uid_exists = True
2492+ except KeyError:
2493+ uid_exists = False
2494+ return uid_exists
2495+
2496+
2497+def group_exists(groupname):
2498+ """Check if a group exists"""
2499+ try:
2500+ grp.getgrnam(groupname)
2501+ group_exists = True
2502+ except KeyError:
2503+ group_exists = False
2504+ return group_exists
2505+
2506+
2507+def gid_exists(gid):
2508+ """Check if a gid exists"""
2509+ try:
2510+ grp.getgrgid(gid)
2511+ gid_exists = True
2512+ except KeyError:
2513+ gid_exists = False
2514+ return gid_exists
2515+
2516+
2517+def add_group(group_name, system_group=False, gid=None):
2518+ """Add a group to the system
2519+
2520+ Will log but otherwise succeed if the group already exists.
2521+
2522+ :param str group_name: group to create
2523+ :param bool system_group: Create system group
2524+ :param int gid: GID for user being created
2525+
2526+ :returns: The password database entry struct, as returned by `grp.getgrnam`
2527+ """
2528+ try:
2529+ group_info = grp.getgrnam(group_name)
2530+ log('group {0} already exists!'.format(group_name))
2531+ if gid:
2532+ group_info = grp.getgrgid(gid)
2533+ log('group with gid {0} already exists!'.format(gid))
2534+ except KeyError:
2535+ log('creating group {0}'.format(group_name))
2536+ add_new_group(group_name, system_group, gid)
2537+ group_info = grp.getgrnam(group_name)
2538+ return group_info
2539+
2540+
2541+def add_user_to_group(username, group):
2542+ """Add a user to a group"""
2543+ cmd = ['gpasswd', '-a', username, group]
2544+ log("Adding user {} to group {}".format(username, group))
2545+ subprocess.check_call(cmd)
2546+
2547+
2548+def rsync(from_path, to_path, flags='-r', options=None, timeout=None):
2549+ """Replicate the contents of a path"""
2550+ options = options or ['--delete', '--executability']
2551+ cmd = ['/usr/bin/rsync', flags]
2552+ if timeout:
2553+ cmd = ['timeout', str(timeout)] + cmd
2554+ cmd.extend(options)
2555+ cmd.append(from_path)
2556+ cmd.append(to_path)
2557+ log(" ".join(cmd))
2558+ return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('UTF-8').strip()
2559+
2560+
2561+def symlink(source, destination):
2562+ """Create a symbolic link"""
2563+ log("Symlinking {} as {}".format(source, destination))
2564+ cmd = [
2565+ 'ln',
2566+ '-sf',
2567+ source,
2568+ destination,
2569+ ]
2570+ subprocess.check_call(cmd)
2571+
2572+
2573+def mkdir(path, owner='root', group='root', perms=0o555, force=False):
2574+ """Create a directory"""
2575+ log("Making dir {} {}:{} {:o}".format(path, owner, group,
2576+ perms))
2577+ uid = pwd.getpwnam(owner).pw_uid
2578+ gid = grp.getgrnam(group).gr_gid
2579+ realpath = os.path.abspath(path)
2580+ path_exists = os.path.exists(realpath)
2581+ if path_exists and force:
2582+ if not os.path.isdir(realpath):
2583+ log("Removing non-directory file {} prior to mkdir()".format(path))
2584+ os.unlink(realpath)
2585+ os.makedirs(realpath, perms)
2586+ elif not path_exists:
2587+ os.makedirs(realpath, perms)
2588+ os.chown(realpath, uid, gid)
2589+ os.chmod(realpath, perms)
2590+
2591+
2592+def write_file(path, content, owner='root', group='root', perms=0o444):
2593+ """Create or overwrite a file with the contents of a byte string."""
2594+ log("Writing file {} {}:{} {:o}".format(path, owner, group, perms))
2595+ uid = pwd.getpwnam(owner).pw_uid
2596+ gid = grp.getgrnam(group).gr_gid
2597+ with open(path, 'wb') as target:
2598+ os.fchown(target.fileno(), uid, gid)
2599+ os.fchmod(target.fileno(), perms)
2600+ target.write(content)
2601+
2602+
2603+def fstab_remove(mp):
2604+ """Remove the given mountpoint entry from /etc/fstab"""
2605+ return Fstab.remove_by_mountpoint(mp)
2606+
2607+
2608+def fstab_add(dev, mp, fs, options=None):
2609+ """Adds the given device entry to the /etc/fstab file"""
2610+ return Fstab.add(dev, mp, fs, options=options)
2611+
2612+
2613+def mount(device, mountpoint, options=None, persist=False, filesystem="ext3"):
2614+ """Mount a filesystem at a particular mountpoint"""
2615+ cmd_args = ['mount']
2616+ if options is not None:
2617+ cmd_args.extend(['-o', options])
2618+ cmd_args.extend([device, mountpoint])
2619+ try:
2620+ subprocess.check_output(cmd_args)
2621+ except subprocess.CalledProcessError as e:
2622+ log('Error mounting {} at {}\n{}'.format(device, mountpoint, e.output))
2623+ return False
2624+
2625+ if persist:
2626+ return fstab_add(device, mountpoint, filesystem, options=options)
2627+ return True
2628+
2629+
2630+def umount(mountpoint, persist=False):
2631+ """Unmount a filesystem"""
2632+ cmd_args = ['umount', mountpoint]
2633+ try:
2634+ subprocess.check_output(cmd_args)
2635+ except subprocess.CalledProcessError as e:
2636+ log('Error unmounting {}\n{}'.format(mountpoint, e.output))
2637+ return False
2638+
2639+ if persist:
2640+ return fstab_remove(mountpoint)
2641+ return True
2642+
2643+
2644+def mounts():
2645+ """Get a list of all mounted volumes as [[mountpoint,device],[...]]"""
2646+ with open('/proc/mounts') as f:
2647+ # [['/mount/point','/dev/path'],[...]]
2648+ system_mounts = [m[1::-1] for m in [l.strip().split()
2649+ for l in f.readlines()]]
2650+ return system_mounts
2651+
2652+
2653+def fstab_mount(mountpoint):
2654+ """Mount filesystem using fstab"""
2655+ cmd_args = ['mount', mountpoint]
2656+ try:
2657+ subprocess.check_output(cmd_args)
2658+ except subprocess.CalledProcessError as e:
2659+ log('Error unmounting {}\n{}'.format(mountpoint, e.output))
2660+ return False
2661+ return True
2662+
2663+
2664+def file_hash(path, hash_type='md5'):
2665+ """Generate a hash checksum of the contents of 'path' or None if not found.
2666+
2667+ :param str hash_type: Any hash alrgorithm supported by :mod:`hashlib`,
2668+ such as md5, sha1, sha256, sha512, etc.
2669+ """
2670+ if os.path.exists(path):
2671+ h = getattr(hashlib, hash_type)()
2672+ with open(path, 'rb') as source:
2673+ h.update(source.read())
2674+ return h.hexdigest()
2675+ else:
2676+ return None
2677+
2678+
2679+def path_hash(path):
2680+ """Generate a hash checksum of all files matching 'path'. Standard
2681+ wildcards like '*' and '?' are supported, see documentation for the 'glob'
2682+ module for more information.
2683+
2684+ :return: dict: A { filename: hash } dictionary for all matched files.
2685+ Empty if none found.
2686+ """
2687+ return {
2688+ filename: file_hash(filename)
2689+ for filename in glob.iglob(path)
2690+ }
2691+
2692+
2693+def check_hash(path, checksum, hash_type='md5'):
2694+ """Validate a file using a cryptographic checksum.
2695+
2696+ :param str checksum: Value of the checksum used to validate the file.
2697+ :param str hash_type: Hash algorithm used to generate `checksum`.
2698+ Can be any hash alrgorithm supported by :mod:`hashlib`,
2699+ such as md5, sha1, sha256, sha512, etc.
2700+ :raises ChecksumError: If the file fails the checksum
2701+
2702+ """
2703+ actual_checksum = file_hash(path, hash_type)
2704+ if checksum != actual_checksum:
2705+ raise ChecksumError("'%s' != '%s'" % (checksum, actual_checksum))
2706+
2707+
2708+class ChecksumError(ValueError):
2709+ """A class derived from Value error to indicate the checksum failed."""
2710+ pass
2711+
2712+
2713+def restart_on_change(restart_map, stopstart=False, restart_functions=None):
2714+ """Restart services based on configuration files changing
2715+
2716+ This function is used a decorator, for example::
2717+
2718+ @restart_on_change({
2719+ '/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
2720+ '/etc/apache/sites-enabled/*': [ 'apache2' ]
2721+ })
2722+ def config_changed():
2723+ pass # your code here
2724+
2725+ In this example, the cinder-api and cinder-volume services
2726+ would be restarted if /etc/ceph/ceph.conf is changed by the
2727+ ceph_client_changed function. The apache2 service would be
2728+ restarted if any file matching the pattern got changed, created
2729+ or removed. Standard wildcards are supported, see documentation
2730+ for the 'glob' module for more information.
2731+
2732+ @param restart_map: {path_file_name: [service_name, ...]
2733+ @param stopstart: DEFAULT false; whether to stop, start OR restart
2734+ @param restart_functions: nonstandard functions to use to restart services
2735+ {svc: func, ...}
2736+ @returns result from decorated function
2737+ """
2738+ def wrap(f):
2739+ @functools.wraps(f)
2740+ def wrapped_f(*args, **kwargs):
2741+ return restart_on_change_helper(
2742+ (lambda: f(*args, **kwargs)), restart_map, stopstart,
2743+ restart_functions)
2744+ return wrapped_f
2745+ return wrap
2746+
2747+
2748+def restart_on_change_helper(lambda_f, restart_map, stopstart=False,
2749+ restart_functions=None):
2750+ """Helper function to perform the restart_on_change function.
2751+
2752+ This is provided for decorators to restart services if files described
2753+ in the restart_map have changed after an invocation of lambda_f().
2754+
2755+ @param lambda_f: function to call.
2756+ @param restart_map: {file: [service, ...]}
2757+ @param stopstart: whether to stop, start or restart a service
2758+ @param restart_functions: nonstandard functions to use to restart services
2759+ {svc: func, ...}
2760+ @returns result of lambda_f()
2761+ """
2762+ if restart_functions is None:
2763+ restart_functions = {}
2764+ checksums = {path: path_hash(path) for path in restart_map}
2765+ r = lambda_f()
2766+ # create a list of lists of the services to restart
2767+ restarts = [restart_map[path]
2768+ for path in restart_map
2769+ if path_hash(path) != checksums[path]]
2770+ # create a flat list of ordered services without duplicates from lists
2771+ services_list = list(OrderedDict.fromkeys(itertools.chain(*restarts)))
2772+ if services_list:
2773+ actions = ('stop', 'start') if stopstart else ('restart',)
2774+ for service_name in services_list:
2775+ if service_name in restart_functions:
2776+ restart_functions[service_name](service_name)
2777+ else:
2778+ for action in actions:
2779+ service(action, service_name)
2780+ return r
2781+
2782+
2783+def pwgen(length=None):
2784+ """Generate a random pasword."""
2785+ if length is None:
2786+ # A random length is ok to use a weak PRNG
2787+ length = random.choice(range(35, 45))
2788+ alphanumeric_chars = [
2789+ l for l in (string.ascii_letters + string.digits)
2790+ if l not in 'l0QD1vAEIOUaeiou']
2791+ # Use a crypto-friendly PRNG (e.g. /dev/urandom) for making the
2792+ # actual password
2793+ random_generator = random.SystemRandom()
2794+ random_chars = [
2795+ random_generator.choice(alphanumeric_chars) for _ in range(length)]
2796+ return(''.join(random_chars))
2797+
2798+
2799+def is_phy_iface(interface):
2800+ """Returns True if interface is not virtual, otherwise False."""
2801+ if interface:
2802+ sys_net = '/sys/class/net'
2803+ if os.path.isdir(sys_net):
2804+ for iface in glob.glob(os.path.join(sys_net, '*')):
2805+ if '/virtual/' in os.path.realpath(iface):
2806+ continue
2807+
2808+ if interface == os.path.basename(iface):
2809+ return True
2810+
2811+ return False
2812+
2813+
2814+def get_bond_master(interface):
2815+ """Returns bond master if interface is bond slave otherwise None.
2816+
2817+ NOTE: the provided interface is expected to be physical
2818+ """
2819+ if interface:
2820+ iface_path = '/sys/class/net/%s' % (interface)
2821+ if os.path.exists(iface_path):
2822+ if '/virtual/' in os.path.realpath(iface_path):
2823+ return None
2824+
2825+ master = os.path.join(iface_path, 'master')
2826+ if os.path.exists(master):
2827+ master = os.path.realpath(master)
2828+ # make sure it is a bond master
2829+ if os.path.exists(os.path.join(master, 'bonding')):
2830+ return os.path.basename(master)
2831+
2832+ return None
2833+
2834+
2835+def list_nics(nic_type=None):
2836+ """Return a list of nics of given type(s)"""
2837+ if isinstance(nic_type, six.string_types):
2838+ int_types = [nic_type]
2839+ else:
2840+ int_types = nic_type
2841+
2842+ interfaces = []
2843+ if nic_type:
2844+ for int_type in int_types:
2845+ cmd = ['ip', 'addr', 'show', 'label', int_type + '*']
2846+ ip_output = subprocess.check_output(cmd).decode('UTF-8')
2847+ ip_output = ip_output.split('\n')
2848+ ip_output = (line for line in ip_output if line)
2849+ for line in ip_output:
2850+ if line.split()[1].startswith(int_type):
2851+ matched = re.search('.*: (' + int_type +
2852+ r'[0-9]+\.[0-9]+)@.*', line)
2853+ if matched:
2854+ iface = matched.groups()[0]
2855+ else:
2856+ iface = line.split()[1].replace(":", "")
2857+
2858+ if iface not in interfaces:
2859+ interfaces.append(iface)
2860+ else:
2861+ cmd = ['ip', 'a']
2862+ ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
2863+ ip_output = (line.strip() for line in ip_output if line)
2864+
2865+ key = re.compile('^[0-9]+:\s+(.+):')
2866+ for line in ip_output:
2867+ matched = re.search(key, line)
2868+ if matched:
2869+ iface = matched.group(1)
2870+ iface = iface.partition("@")[0]
2871+ if iface not in interfaces:
2872+ interfaces.append(iface)
2873+
2874+ return interfaces
2875+
2876+
2877+def set_nic_mtu(nic, mtu):
2878+ """Set the Maximum Transmission Unit (MTU) on a network interface."""
2879+ cmd = ['ip', 'link', 'set', nic, 'mtu', mtu]
2880+ subprocess.check_call(cmd)
2881+
2882+
2883+def get_nic_mtu(nic):
2884+ """Return the Maximum Transmission Unit (MTU) for a network interface."""
2885+ cmd = ['ip', 'addr', 'show', nic]
2886+ ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
2887+ mtu = ""
2888+ for line in ip_output:
2889+ words = line.split()
2890+ if 'mtu' in words:
2891+ mtu = words[words.index("mtu") + 1]
2892+ return mtu
2893+
2894+
2895+def get_nic_hwaddr(nic):
2896+ """Return the Media Access Control (MAC) for a network interface."""
2897+ cmd = ['ip', '-o', '-0', 'addr', 'show', nic]
2898+ ip_output = subprocess.check_output(cmd).decode('UTF-8')
2899+ hwaddr = ""
2900+ words = ip_output.split()
2901+ if 'link/ether' in words:
2902+ hwaddr = words[words.index('link/ether') + 1]
2903+ return hwaddr
2904+
2905+
2906+@contextmanager
2907+def chdir(directory):
2908+ """Change the current working directory to a different directory for a code
2909+ block and return the previous directory after the block exits. Useful to
2910+ run commands from a specificed directory.
2911+
2912+ :param str directory: The directory path to change to for this context.
2913+ """
2914+ cur = os.getcwd()
2915+ try:
2916+ yield os.chdir(directory)
2917+ finally:
2918+ os.chdir(cur)
2919+
2920+
2921+def chownr(path, owner, group, follow_links=True, chowntopdir=False):
2922+ """Recursively change user and group ownership of files and directories
2923+ in given path. Doesn't chown path itself by default, only its children.
2924+
2925+ :param str path: The string path to start changing ownership.
2926+ :param str owner: The owner string to use when looking up the uid.
2927+ :param str group: The group string to use when looking up the gid.
2928+ :param bool follow_links: Also follow and chown links if True
2929+ :param bool chowntopdir: Also chown path itself if True
2930+ """
2931+ uid = pwd.getpwnam(owner).pw_uid
2932+ gid = grp.getgrnam(group).gr_gid
2933+ if follow_links:
2934+ chown = os.chown
2935+ else:
2936+ chown = os.lchown
2937+
2938+ if chowntopdir:
2939+ broken_symlink = os.path.lexists(path) and not os.path.exists(path)
2940+ if not broken_symlink:
2941+ chown(path, uid, gid)
2942+ for root, dirs, files in os.walk(path, followlinks=follow_links):
2943+ for name in dirs + files:
2944+ full = os.path.join(root, name)
2945+ broken_symlink = os.path.lexists(full) and not os.path.exists(full)
2946+ if not broken_symlink:
2947+ chown(full, uid, gid)
2948+
2949+
2950+def lchownr(path, owner, group):
2951+ """Recursively change user and group ownership of files and directories
2952+ in a given path, not following symbolic links. See the documentation for
2953+ 'os.lchown' for more information.
2954+
2955+ :param str path: The string path to start changing ownership.
2956+ :param str owner: The owner string to use when looking up the uid.
2957+ :param str group: The group string to use when looking up the gid.
2958+ """
2959+ chownr(path, owner, group, follow_links=False)
2960+
2961+
2962+def owner(path):
2963+ """Returns a tuple containing the username & groupname owning the path.
2964+
2965+ :param str path: the string path to retrieve the ownership
2966+ :return tuple(str, str): A (username, groupname) tuple containing the
2967+ name of the user and group owning the path.
2968+ :raises OSError: if the specified path does not exist
2969+ """
2970+ stat = os.stat(path)
2971+ username = pwd.getpwuid(stat.st_uid)[0]
2972+ groupname = grp.getgrgid(stat.st_gid)[0]
2973+ return username, groupname
2974+
2975+
2976+def get_total_ram():
2977+ """The total amount of system RAM in bytes.
2978+
2979+ This is what is reported by the OS, and may be overcommitted when
2980+ there are multiple containers hosted on the same machine.
2981+ """
2982+ with open('/proc/meminfo', 'r') as f:
2983+ for line in f.readlines():
2984+ if line:
2985+ key, value, unit = line.split()
2986+ if key == 'MemTotal:':
2987+ assert unit == 'kB', 'Unknown unit'
2988+ return int(value) * 1024 # Classic, not KiB.
2989+ raise NotImplementedError()
2990+
2991+
2992+UPSTART_CONTAINER_TYPE = '/run/container_type'
2993+
2994+
2995+def is_container():
2996+ """Determine whether unit is running in a container
2997+
2998+ @return: boolean indicating if unit is in a container
2999+ """
3000+ if init_is_systemd():
3001+ # Detect using systemd-detect-virt
3002+ return subprocess.call(['systemd-detect-virt',
3003+ '--container']) == 0
3004+ else:
3005+ # Detect using upstart container file marker
3006+ return os.path.exists(UPSTART_CONTAINER_TYPE)
3007+
3008+
3009+def add_to_updatedb_prunepath(path, updatedb_path=UPDATEDB_PATH):
3010+ with open(updatedb_path, 'r+') as f_id:
3011+ updatedb_text = f_id.read()
3012+ output = updatedb(updatedb_text, path)
3013+ f_id.seek(0)
3014+ f_id.write(output)
3015+ f_id.truncate()
3016+
3017+
3018+def updatedb(updatedb_text, new_path):
3019+ lines = [line for line in updatedb_text.split("\n")]
3020+ for i, line in enumerate(lines):
3021+ if line.startswith("PRUNEPATHS="):
3022+ paths_line = line.split("=")[1].replace('"', '')
3023+ paths = paths_line.split(" ")
3024+ if new_path not in paths:
3025+ paths.append(new_path)
3026+ lines[i] = 'PRUNEPATHS="{}"'.format(' '.join(paths))
3027+ output = "\n".join(lines)
3028+ return output
3029diff --git a/hooks/charmhelpers/core/host_factory/__init__.py b/hooks/charmhelpers/core/host_factory/__init__.py
3030new file mode 100644
3031index 0000000..e69de29
3032--- /dev/null
3033+++ b/hooks/charmhelpers/core/host_factory/__init__.py
3034diff --git a/hooks/charmhelpers/core/host_factory/centos.py b/hooks/charmhelpers/core/host_factory/centos.py
3035new file mode 100644
3036index 0000000..7781a39
3037--- /dev/null
3038+++ b/hooks/charmhelpers/core/host_factory/centos.py
3039@@ -0,0 +1,72 @@
3040+import subprocess
3041+import yum
3042+import os
3043+
3044+from charmhelpers.core.strutils import BasicStringComparator
3045+
3046+
3047+class CompareHostReleases(BasicStringComparator):
3048+ """Provide comparisons of Host releases.
3049+
3050+ Use in the form of
3051+
3052+ if CompareHostReleases(release) > 'trusty':
3053+ # do something with mitaka
3054+ """
3055+
3056+ def __init__(self, item):
3057+ raise NotImplementedError(
3058+ "CompareHostReleases() is not implemented for CentOS")
3059+
3060+
3061+def service_available(service_name):
3062+ # """Determine whether a system service is available."""
3063+ if os.path.isdir('/run/systemd/system'):
3064+ cmd = ['systemctl', 'is-enabled', service_name]
3065+ else:
3066+ cmd = ['service', service_name, 'is-enabled']
3067+ return subprocess.call(cmd) == 0
3068+
3069+
3070+def add_new_group(group_name, system_group=False, gid=None):
3071+ cmd = ['groupadd']
3072+ if gid:
3073+ cmd.extend(['--gid', str(gid)])
3074+ if system_group:
3075+ cmd.append('-r')
3076+ cmd.append(group_name)
3077+ subprocess.check_call(cmd)
3078+
3079+
3080+def lsb_release():
3081+ """Return /etc/os-release in a dict."""
3082+ d = {}
3083+ with open('/etc/os-release', 'r') as lsb:
3084+ for l in lsb:
3085+ s = l.split('=')
3086+ if len(s) != 2:
3087+ continue
3088+ d[s[0].strip()] = s[1].strip()
3089+ return d
3090+
3091+
3092+def cmp_pkgrevno(package, revno, pkgcache=None):
3093+ """Compare supplied revno with the revno of the installed package.
3094+
3095+ * 1 => Installed revno is greater than supplied arg
3096+ * 0 => Installed revno is the same as supplied arg
3097+ * -1 => Installed revno is less than supplied arg
3098+
3099+ This function imports YumBase function if the pkgcache argument
3100+ is None.
3101+ """
3102+ if not pkgcache:
3103+ y = yum.YumBase()
3104+ packages = y.doPackageLists()
3105+ pkgcache = {i.Name: i.version for i in packages['installed']}
3106+ pkg = pkgcache[package]
3107+ if pkg > revno:
3108+ return 1
3109+ if pkg < revno:
3110+ return -1
3111+ return 0
3112diff --git a/hooks/charmhelpers/core/host_factory/ubuntu.py b/hooks/charmhelpers/core/host_factory/ubuntu.py
3113new file mode 100644
3114index 0000000..0448288
3115--- /dev/null
3116+++ b/hooks/charmhelpers/core/host_factory/ubuntu.py
3117@@ -0,0 +1,88 @@
3118+import subprocess
3119+
3120+from charmhelpers.core.strutils import BasicStringComparator
3121+
3122+
3123+UBUNTU_RELEASES = (
3124+ 'lucid',
3125+ 'maverick',
3126+ 'natty',
3127+ 'oneiric',
3128+ 'precise',
3129+ 'quantal',
3130+ 'raring',
3131+ 'saucy',
3132+ 'trusty',
3133+ 'utopic',
3134+ 'vivid',
3135+ 'wily',
3136+ 'xenial',
3137+ 'yakkety',
3138+ 'zesty',
3139+)
3140+
3141+
3142+class CompareHostReleases(BasicStringComparator):
3143+ """Provide comparisons of Ubuntu releases.
3144+
3145+ Use in the form of
3146+
3147+ if CompareHostReleases(release) > 'trusty':
3148+ # do something with mitaka
3149+ """
3150+ _list = UBUNTU_RELEASES
3151+
3152+
3153+def service_available(service_name):
3154+ """Determine whether a system service is available"""
3155+ try:
3156+ subprocess.check_output(
3157+ ['service', service_name, 'status'],
3158+ stderr=subprocess.STDOUT).decode('UTF-8')
3159+ except subprocess.CalledProcessError as e:
3160+ return b'unrecognized service' not in e.output
3161+ else:
3162+ return True
3163+
3164+
3165+def add_new_group(group_name, system_group=False, gid=None):
3166+ cmd = ['addgroup']
3167+ if gid:
3168+ cmd.extend(['--gid', str(gid)])
3169+ if system_group:
3170+ cmd.append('--system')
3171+ else:
3172+ cmd.extend([
3173+ '--group',
3174+ ])
3175+ cmd.append(group_name)
3176+ subprocess.check_call(cmd)
3177+
3178+
3179+def lsb_release():
3180+ """Return /etc/lsb-release in a dict"""
3181+ d = {}
3182+ with open('/etc/lsb-release', 'r') as lsb:
3183+ for l in lsb:
3184+ k, v = l.split('=')
3185+ d[k.strip()] = v.strip()
3186+ return d
3187+
3188+
3189+def cmp_pkgrevno(package, revno, pkgcache=None):
3190+ """Compare supplied revno with the revno of the installed package.
3191+
3192+ * 1 => Installed revno is greater than supplied arg
3193+ * 0 => Installed revno is the same as supplied arg
3194+ * -1 => Installed revno is less than supplied arg
3195+
3196+ This function imports apt_cache function from charmhelpers.fetch if
3197+ the pkgcache argument is None. Be sure to add charmhelpers.fetch if
3198+ you call this function, or pass an apt_pkg.Cache() instance.
3199+ """
3200+ import apt_pkg
3201+ if not pkgcache:
3202+ from charmhelpers.fetch import apt_cache
3203+ pkgcache = apt_cache()
3204+ pkg = pkgcache[package]
3205+ return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
3206diff --git a/hooks/charmhelpers/core/hugepage.py b/hooks/charmhelpers/core/hugepage.py
3207new file mode 100644
3208index 0000000..54b5b5e
3209--- /dev/null
3210+++ b/hooks/charmhelpers/core/hugepage.py
3211@@ -0,0 +1,69 @@
3212+# -*- coding: utf-8 -*-
3213+
3214+# Copyright 2014-2015 Canonical Limited.
3215+#
3216+# Licensed under the Apache License, Version 2.0 (the "License");
3217+# you may not use this file except in compliance with the License.
3218+# You may obtain a copy of the License at
3219+#
3220+# http://www.apache.org/licenses/LICENSE-2.0
3221+#
3222+# Unless required by applicable law or agreed to in writing, software
3223+# distributed under the License is distributed on an "AS IS" BASIS,
3224+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3225+# See the License for the specific language governing permissions and
3226+# limitations under the License.
3227+
3228+import yaml
3229+from charmhelpers.core import fstab
3230+from charmhelpers.core import sysctl
3231+from charmhelpers.core.host import (
3232+ add_group,
3233+ add_user_to_group,
3234+ fstab_mount,
3235+ mkdir,
3236+)
3237+from charmhelpers.core.strutils import bytes_from_string
3238+from subprocess import check_output
3239+
3240+
3241+def hugepage_support(user, group='hugetlb', nr_hugepages=256,
3242+ max_map_count=65536, mnt_point='/run/hugepages/kvm',
3243+ pagesize='2MB', mount=True, set_shmmax=False):
3244+ """Enable hugepages on system.
3245+
3246+ Args:
3247+ user (str) -- Username to allow access to hugepages to
3248+ group (str) -- Group name to own hugepages
3249+ nr_hugepages (int) -- Number of pages to reserve
3250+ max_map_count (int) -- Number of Virtual Memory Areas a process can own
3251+ mnt_point (str) -- Directory to mount hugepages on
3252+ pagesize (str) -- Size of hugepages
3253+ mount (bool) -- Whether to Mount hugepages
3254+ """
3255+ group_info = add_group(group)
3256+ gid = group_info.gr_gid
3257+ add_user_to_group(user, group)
3258+ if max_map_count < 2 * nr_hugepages:
3259+ max_map_count = 2 * nr_hugepages
3260+ sysctl_settings = {
3261+ 'vm.nr_hugepages': nr_hugepages,
3262+ 'vm.max_map_count': max_map_count,
3263+ 'vm.hugetlb_shm_group': gid,
3264+ }
3265+ if set_shmmax:
3266+ shmmax_current = int(check_output(['sysctl', '-n', 'kernel.shmmax']))
3267+ shmmax_minsize = bytes_from_string(pagesize) * nr_hugepages
3268+ if shmmax_minsize > shmmax_current:
3269+ sysctl_settings['kernel.shmmax'] = shmmax_minsize
3270+ sysctl.create(yaml.dump(sysctl_settings), '/etc/sysctl.d/10-hugepage.conf')
3271+ mkdir(mnt_point, owner='root', group='root', perms=0o755, force=False)
3272+ lfstab = fstab.Fstab()
3273+ fstab_entry = lfstab.get_entry_by_attr('mountpoint', mnt_point)
3274+ if fstab_entry:
3275+ lfstab.remove_entry(fstab_entry)
3276+ entry = lfstab.Entry('nodev', mnt_point, 'hugetlbfs',
3277+ 'mode=1770,gid={},pagesize={}'.format(gid, pagesize), 0, 0)
3278+ lfstab.add_entry(entry)
3279+ if mount:
3280+ fstab_mount(mnt_point)
3281diff --git a/hooks/charmhelpers/core/kernel.py b/hooks/charmhelpers/core/kernel.py
3282new file mode 100644
3283index 0000000..2d40452
3284--- /dev/null
3285+++ b/hooks/charmhelpers/core/kernel.py
3286@@ -0,0 +1,72 @@
3287+#!/usr/bin/env python
3288+# -*- coding: utf-8 -*-
3289+
3290+# Copyright 2014-2015 Canonical Limited.
3291+#
3292+# Licensed under the Apache License, Version 2.0 (the "License");
3293+# you may not use this file except in compliance with the License.
3294+# You may obtain a copy of the License at
3295+#
3296+# http://www.apache.org/licenses/LICENSE-2.0
3297+#
3298+# Unless required by applicable law or agreed to in writing, software
3299+# distributed under the License is distributed on an "AS IS" BASIS,
3300+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3301+# See the License for the specific language governing permissions and
3302+# limitations under the License.
3303+
3304+import re
3305+import subprocess
3306+
3307+from charmhelpers.osplatform import get_platform
3308+from charmhelpers.core.hookenv import (
3309+ log,
3310+ INFO
3311+)
3312+
3313+__platform__ = get_platform()
3314+if __platform__ == "ubuntu":
3315+ from charmhelpers.core.kernel_factory.ubuntu import (
3316+ persistent_modprobe,
3317+ update_initramfs,
3318+ ) # flake8: noqa -- ignore F401 for this import
3319+elif __platform__ == "centos":
3320+ from charmhelpers.core.kernel_factory.centos import (
3321+ persistent_modprobe,
3322+ update_initramfs,
3323+ ) # flake8: noqa -- ignore F401 for this import
3324+
3325+__author__ = "Jorge Niedbalski <jorge.niedbalski@canonical.com>"
3326+
3327+
3328+def modprobe(module, persist=True):
3329+ """Load a kernel module and configure for auto-load on reboot."""
3330+ cmd = ['modprobe', module]
3331+
3332+ log('Loading kernel module %s' % module, level=INFO)
3333+
3334+ subprocess.check_call(cmd)
3335+ if persist:
3336+ persistent_modprobe(module)
3337+
3338+
3339+def rmmod(module, force=False):
3340+ """Remove a module from the linux kernel"""
3341+ cmd = ['rmmod']
3342+ if force:
3343+ cmd.append('-f')
3344+ cmd.append(module)
3345+ log('Removing kernel module %s' % module, level=INFO)
3346+ return subprocess.check_call(cmd)
3347+
3348+
3349+def lsmod():
3350+ """Shows what kernel modules are currently loaded"""
3351+ return subprocess.check_output(['lsmod'],
3352+ universal_newlines=True)
3353+
3354+
3355+def is_module_loaded(module):
3356+ """Checks if a kernel module is already loaded"""
3357+ matches = re.findall('^%s[ ]+' % module, lsmod(), re.M)
3358+ return len(matches) > 0
3359diff --git a/hooks/charmhelpers/core/kernel_factory/__init__.py b/hooks/charmhelpers/core/kernel_factory/__init__.py
3360new file mode 100644
3361index 0000000..e69de29
3362--- /dev/null
3363+++ b/hooks/charmhelpers/core/kernel_factory/__init__.py
3364diff --git a/hooks/charmhelpers/core/kernel_factory/centos.py b/hooks/charmhelpers/core/kernel_factory/centos.py
3365new file mode 100644
3366index 0000000..1c402c1
3367--- /dev/null
3368+++ b/hooks/charmhelpers/core/kernel_factory/centos.py
3369@@ -0,0 +1,17 @@
3370+import subprocess
3371+import os
3372+
3373+
3374+def persistent_modprobe(module):
3375+ """Load a kernel module and configure for auto-load on reboot."""
3376+ if not os.path.exists('/etc/rc.modules'):
3377+ open('/etc/rc.modules', 'a')
3378+ os.chmod('/etc/rc.modules', 111)
3379+ with open('/etc/rc.modules', 'r+') as modules:
3380+ if module not in modules.read():
3381+ modules.write('modprobe %s\n' % module)
3382+
3383+
3384+def update_initramfs(version='all'):
3385+ """Updates an initramfs image."""
3386+ return subprocess.check_call(["dracut", "-f", version])
3387diff --git a/hooks/charmhelpers/core/kernel_factory/ubuntu.py b/hooks/charmhelpers/core/kernel_factory/ubuntu.py
3388new file mode 100644
3389index 0000000..3de372f
3390--- /dev/null
3391+++ b/hooks/charmhelpers/core/kernel_factory/ubuntu.py
3392@@ -0,0 +1,13 @@
3393+import subprocess
3394+
3395+
3396+def persistent_modprobe(module):
3397+ """Load a kernel module and configure for auto-load on reboot."""
3398+ with open('/etc/modules', 'r+') as modules:
3399+ if module not in modules.read():
3400+ modules.write(module + "\n")
3401+
3402+
3403+def update_initramfs(version='all'):
3404+ """Updates an initramfs image."""
3405+ return subprocess.check_call(["update-initramfs", "-k", version, "-u"])
3406diff --git a/hooks/charmhelpers/core/services/__init__.py b/hooks/charmhelpers/core/services/__init__.py
3407new file mode 100644
3408index 0000000..61fd074
3409--- /dev/null
3410+++ b/hooks/charmhelpers/core/services/__init__.py
3411@@ -0,0 +1,16 @@
3412+# Copyright 2014-2015 Canonical Limited.
3413+#
3414+# Licensed under the Apache License, Version 2.0 (the "License");
3415+# you may not use this file except in compliance with the License.
3416+# You may obtain a copy of the License at
3417+#
3418+# http://www.apache.org/licenses/LICENSE-2.0
3419+#
3420+# Unless required by applicable law or agreed to in writing, software
3421+# distributed under the License is distributed on an "AS IS" BASIS,
3422+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3423+# See the License for the specific language governing permissions and
3424+# limitations under the License.
3425+
3426+from .base import * # NOQA
3427+from .helpers import * # NOQA
3428diff --git a/hooks/charmhelpers/core/services/base.py b/hooks/charmhelpers/core/services/base.py
3429new file mode 100644
3430index 0000000..ca9dc99
3431--- /dev/null
3432+++ b/hooks/charmhelpers/core/services/base.py
3433@@ -0,0 +1,351 @@
3434+# Copyright 2014-2015 Canonical Limited.
3435+#
3436+# Licensed under the Apache License, Version 2.0 (the "License");
3437+# you may not use this file except in compliance with the License.
3438+# You may obtain a copy of the License at
3439+#
3440+# http://www.apache.org/licenses/LICENSE-2.0
3441+#
3442+# Unless required by applicable law or agreed to in writing, software
3443+# distributed under the License is distributed on an "AS IS" BASIS,
3444+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3445+# See the License for the specific language governing permissions and
3446+# limitations under the License.
3447+
3448+import os
3449+import json
3450+from inspect import getargspec
3451+from collections import Iterable, OrderedDict
3452+
3453+from charmhelpers.core import host
3454+from charmhelpers.core import hookenv
3455+
3456+
3457+__all__ = ['ServiceManager', 'ManagerCallback',
3458+ 'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
3459+ 'service_restart', 'service_stop']
3460+
3461+
3462+class ServiceManager(object):
3463+ def __init__(self, services=None):
3464+ """
3465+ Register a list of services, given their definitions.
3466+
3467+ Service definitions are dicts in the following formats (all keys except
3468+ 'service' are optional)::
3469+
3470+ {
3471+ "service": <service name>,
3472+ "required_data": <list of required data contexts>,
3473+ "provided_data": <list of provided data contexts>,
3474+ "data_ready": <one or more callbacks>,
3475+ "data_lost": <one or more callbacks>,
3476+ "start": <one or more callbacks>,
3477+ "stop": <one or more callbacks>,
3478+ "ports": <list of ports to manage>,
3479+ }
3480+
3481+ The 'required_data' list should contain dicts of required data (or
3482+ dependency managers that act like dicts and know how to collect the data).
3483+ Only when all items in the 'required_data' list are populated are the list
3484+ of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
3485+ information.
3486+
3487+ The 'provided_data' list should contain relation data providers, most likely
3488+ a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
3489+ that will indicate a set of data to set on a given relation.
3490+
3491+ The 'data_ready' value should be either a single callback, or a list of
3492+ callbacks, to be called when all items in 'required_data' pass `is_ready()`.
3493+ Each callback will be called with the service name as the only parameter.
3494+ After all of the 'data_ready' callbacks are called, the 'start' callbacks
3495+ are fired.
3496+
3497+ The 'data_lost' value should be either a single callback, or a list of
3498+ callbacks, to be called when a 'required_data' item no longer passes
3499+ `is_ready()`. Each callback will be called with the service name as the
3500+ only parameter. After all of the 'data_lost' callbacks are called,
3501+ the 'stop' callbacks are fired.
3502+
3503+ The 'start' value should be either a single callback, or a list of
3504+ callbacks, to be called when starting the service, after the 'data_ready'
3505+ callbacks are complete. Each callback will be called with the service
3506+ name as the only parameter. This defaults to
3507+ `[host.service_start, services.open_ports]`.
3508+
3509+ The 'stop' value should be either a single callback, or a list of
3510+ callbacks, to be called when stopping the service. If the service is
3511+ being stopped because it no longer has all of its 'required_data', this
3512+ will be called after all of the 'data_lost' callbacks are complete.
3513+ Each callback will be called with the service name as the only parameter.
3514+ This defaults to `[services.close_ports, host.service_stop]`.
3515+
3516+ The 'ports' value should be a list of ports to manage. The default
3517+ 'start' handler will open the ports after the service is started,
3518+ and the default 'stop' handler will close the ports prior to stopping
3519+ the service.
3520+
3521+
3522+ Examples:
3523+
3524+ The following registers an Upstart service called bingod that depends on
3525+ a mongodb relation and which runs a custom `db_migrate` function prior to
3526+ restarting the service, and a Runit service called spadesd::
3527+
3528+ manager = services.ServiceManager([
3529+ {
3530+ 'service': 'bingod',
3531+ 'ports': [80, 443],
3532+ 'required_data': [MongoRelation(), config(), {'my': 'data'}],
3533+ 'data_ready': [
3534+ services.template(source='bingod.conf'),
3535+ services.template(source='bingod.ini',
3536+ target='/etc/bingod.ini',
3537+ owner='bingo', perms=0400),
3538+ ],
3539+ },
3540+ {
3541+ 'service': 'spadesd',
3542+ 'data_ready': services.template(source='spadesd_run.j2',
3543+ target='/etc/sv/spadesd/run',
3544+ perms=0555),
3545+ 'start': runit_start,
3546+ 'stop': runit_stop,
3547+ },
3548+ ])
3549+ manager.manage()
3550+ """
3551+ self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
3552+ self._ready = None
3553+ self.services = OrderedDict()
3554+ for service in services or []:
3555+ service_name = service['service']
3556+ self.services[service_name] = service
3557+
3558+ def manage(self):
3559+ """
3560+ Handle the current hook by doing The Right Thing with the registered services.
3561+ """
3562+ hookenv._run_atstart()
3563+ try:
3564+ hook_name = hookenv.hook_name()
3565+ if hook_name == 'stop':
3566+ self.stop_services()
3567+ else:
3568+ self.reconfigure_services()
3569+ self.provide_data()
3570+ except SystemExit as x:
3571+ if x.code is None or x.code == 0:
3572+ hookenv._run_atexit()
3573+ hookenv._run_atexit()
3574+
3575+ def provide_data(self):
3576+ """
3577+ Set the relation data for each provider in the ``provided_data`` list.
3578+
3579+ A provider must have a `name` attribute, which indicates which relation
3580+ to set data on, and a `provide_data()` method, which returns a dict of
3581+ data to set.
3582+
3583+ The `provide_data()` method can optionally accept two parameters:
3584+
3585+ * ``remote_service`` The name of the remote service that the data will
3586+ be provided to. The `provide_data()` method will be called once
3587+ for each connected service (not unit). This allows the method to
3588+ tailor its data to the given service.
3589+ * ``service_ready`` Whether or not the service definition had all of
3590+ its requirements met, and thus the ``data_ready`` callbacks run.
3591+
3592+ Note that the ``provided_data`` methods are now called **after** the
3593+ ``data_ready`` callbacks are run. This gives the ``data_ready`` callbacks
3594+ a chance to generate any data necessary for the providing to the remote
3595+ services.
3596+ """
3597+ for service_name, service in self.services.items():
3598+ service_ready = self.is_ready(service_name)
3599+ for provider in service.get('provided_data', []):
3600+ for relid in hookenv.relation_ids(provider.name):
3601+ units = hookenv.related_units(relid)
3602+ if not units:
3603+ continue
3604+ remote_service = units[0].split('/')[0]
3605+ argspec = getargspec(provider.provide_data)
3606+ if len(argspec.args) > 1:
3607+ data = provider.provide_data(remote_service, service_ready)
3608+ else:
3609+ data = provider.provide_data()
3610+ if data:
3611+ hookenv.relation_set(relid, data)
3612+
3613+ def reconfigure_services(self, *service_names):
3614+ """
3615+ Update all files for one or more registered services, and,
3616+ if ready, optionally restart them.
3617+
3618+ If no service names are given, reconfigures all registered services.
3619+ """
3620+ for service_name in service_names or self.services.keys():
3621+ if self.is_ready(service_name):
3622+ self.fire_event('data_ready', service_name)
3623+ self.fire_event('start', service_name, default=[
3624+ service_restart,
3625+ manage_ports])
3626+ self.save_ready(service_name)
3627+ else:
3628+ if self.was_ready(service_name):
3629+ self.fire_event('data_lost', service_name)
3630+ self.fire_event('stop', service_name, default=[
3631+ manage_ports,
3632+ service_stop])
3633+ self.save_lost(service_name)
3634+
3635+ def stop_services(self, *service_names):
3636+ """
3637+ Stop one or more registered services, by name.
3638+
3639+ If no service names are given, stops all registered services.
3640+ """
3641+ for service_name in service_names or self.services.keys():
3642+ self.fire_event('stop', service_name, default=[
3643+ manage_ports,
3644+ service_stop])
3645+
3646+ def get_service(self, service_name):
3647+ """
3648+ Given the name of a registered service, return its service definition.
3649+ """
3650+ service = self.services.get(service_name)
3651+ if not service:
3652+ raise KeyError('Service not registered: %s' % service_name)
3653+ return service
3654+
3655+ def fire_event(self, event_name, service_name, default=None):
3656+ """
3657+ Fire a data_ready, data_lost, start, or stop event on a given service.
3658+ """
3659+ service = self.get_service(service_name)
3660+ callbacks = service.get(event_name, default)
3661+ if not callbacks:
3662+ return
3663+ if not isinstance(callbacks, Iterable):
3664+ callbacks = [callbacks]
3665+ for callback in callbacks:
3666+ if isinstance(callback, ManagerCallback):
3667+ callback(self, service_name, event_name)
3668+ else:
3669+ callback(service_name)
3670+
3671+ def is_ready(self, service_name):
3672+ """
3673+ Determine if a registered service is ready, by checking its 'required_data'.
3674+
3675+ A 'required_data' item can be any mapping type, and is considered ready
3676+ if `bool(item)` evaluates as True.
3677+ """
3678+ service = self.get_service(service_name)
3679+ reqs = service.get('required_data', [])
3680+ return all(bool(req) for req in reqs)
3681+
3682+ def _load_ready_file(self):
3683+ if self._ready is not None:
3684+ return
3685+ if os.path.exists(self._ready_file):
3686+ with open(self._ready_file) as fp:
3687+ self._ready = set(json.load(fp))
3688+ else:
3689+ self._ready = set()
3690+
3691+ def _save_ready_file(self):
3692+ if self._ready is None:
3693+ return
3694+ with open(self._ready_file, 'w') as fp:
3695+ json.dump(list(self._ready), fp)
3696+
3697+ def save_ready(self, service_name):
3698+ """
3699+ Save an indicator that the given service is now data_ready.
3700+ """
3701+ self._load_ready_file()
3702+ self._ready.add(service_name)
3703+ self._save_ready_file()
3704+
3705+ def save_lost(self, service_name):
3706+ """
3707+ Save an indicator that the given service is no longer data_ready.
3708+ """
3709+ self._load_ready_file()
3710+ self._ready.discard(service_name)
3711+ self._save_ready_file()
3712+
3713+ def was_ready(self, service_name):
3714+ """
3715+ Determine if the given service was previously data_ready.
3716+ """
3717+ self._load_ready_file()
3718+ return service_name in self._ready
3719+
3720+
3721+class ManagerCallback(object):
3722+ """
3723+ Special case of a callback that takes the `ServiceManager` instance
3724+ in addition to the service name.
3725+
3726+ Subclasses should implement `__call__` which should accept three parameters:
3727+
3728+ * `manager` The `ServiceManager` instance
3729+ * `service_name` The name of the service it's being triggered for
3730+ * `event_name` The name of the event that this callback is handling
3731+ """
3732+ def __call__(self, manager, service_name, event_name):
3733+ raise NotImplementedError()
3734+
3735+
3736+class PortManagerCallback(ManagerCallback):
3737+ """
3738+ Callback class that will open or close ports, for use as either
3739+ a start or stop action.
3740+ """
3741+ def __call__(self, manager, service_name, event_name):
3742+ service = manager.get_service(service_name)
3743+ new_ports = service.get('ports', [])
3744+ port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
3745+ if os.path.exists(port_file):
3746+ with open(port_file) as fp:
3747+ old_ports = fp.read().split(',')
3748+ for old_port in old_ports:
3749+ if bool(old_port):
3750+ old_port = int(old_port)
3751+ if old_port not in new_ports:
3752+ hookenv.close_port(old_port)
3753+ with open(port_file, 'w') as fp:
3754+ fp.write(','.join(str(port) for port in new_ports))
3755+ for port in new_ports:
3756+ if event_name == 'start':
3757+ hookenv.open_port(port)
3758+ elif event_name == 'stop':
3759+ hookenv.close_port(port)
3760+
3761+
3762+def service_stop(service_name):
3763+ """
3764+ Wrapper around host.service_stop to prevent spurious "unknown service"
3765+ messages in the logs.
3766+ """
3767+ if host.service_running(service_name):
3768+ host.service_stop(service_name)
3769+
3770+
3771+def service_restart(service_name):
3772+ """
3773+ Wrapper around host.service_restart to prevent spurious "unknown service"
3774+ messages in the logs.
3775+ """
3776+ if host.service_available(service_name):
3777+ if host.service_running(service_name):
3778+ host.service_restart(service_name)
3779+ else:
3780+ host.service_start(service_name)
3781+
3782+
3783+# Convenience aliases
3784+open_ports = close_ports = manage_ports = PortManagerCallback()
3785diff --git a/hooks/charmhelpers/core/services/helpers.py b/hooks/charmhelpers/core/services/helpers.py
3786new file mode 100644
3787index 0000000..3e6e30d
3788--- /dev/null
3789+++ b/hooks/charmhelpers/core/services/helpers.py
3790@@ -0,0 +1,290 @@
3791+# Copyright 2014-2015 Canonical Limited.
3792+#
3793+# Licensed under the Apache License, Version 2.0 (the "License");
3794+# you may not use this file except in compliance with the License.
3795+# You may obtain a copy of the License at
3796+#
3797+# http://www.apache.org/licenses/LICENSE-2.0
3798+#
3799+# Unless required by applicable law or agreed to in writing, software
3800+# distributed under the License is distributed on an "AS IS" BASIS,
3801+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3802+# See the License for the specific language governing permissions and
3803+# limitations under the License.
3804+
3805+import os
3806+import yaml
3807+
3808+from charmhelpers.core import hookenv
3809+from charmhelpers.core import host
3810+from charmhelpers.core import templating
3811+
3812+from charmhelpers.core.services.base import ManagerCallback
3813+
3814+
3815+__all__ = ['RelationContext', 'TemplateCallback',
3816+ 'render_template', 'template']
3817+
3818+
3819+class RelationContext(dict):
3820+ """
3821+ Base class for a context generator that gets relation data from juju.
3822+
3823+ Subclasses must provide the attributes `name`, which is the name of the
3824+ interface of interest, `interface`, which is the type of the interface of
3825+ interest, and `required_keys`, which is the set of keys required for the
3826+ relation to be considered complete. The data for all interfaces matching
3827+ the `name` attribute that are complete will used to populate the dictionary
3828+ values (see `get_data`, below).
3829+
3830+ The generated context will be namespaced under the relation :attr:`name`,
3831+ to prevent potential naming conflicts.
3832+
3833+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
3834+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
3835+ """
3836+ name = None
3837+ interface = None
3838+
3839+ def __init__(self, name=None, additional_required_keys=None):
3840+ if not hasattr(self, 'required_keys'):
3841+ self.required_keys = []
3842+
3843+ if name is not None:
3844+ self.name = name
3845+ if additional_required_keys:
3846+ self.required_keys.extend(additional_required_keys)
3847+ self.get_data()
3848+
3849+ def __bool__(self):
3850+ """
3851+ Returns True if all of the required_keys are available.
3852+ """
3853+ return self.is_ready()
3854+
3855+ __nonzero__ = __bool__
3856+
3857+ def __repr__(self):
3858+ return super(RelationContext, self).__repr__()
3859+
3860+ def is_ready(self):
3861+ """
3862+ Returns True if all of the `required_keys` are available from any units.
3863+ """
3864+ ready = len(self.get(self.name, [])) > 0
3865+ if not ready:
3866+ hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
3867+ return ready
3868+
3869+ def _is_ready(self, unit_data):
3870+ """
3871+ Helper method that tests a set of relation data and returns True if
3872+ all of the `required_keys` are present.
3873+ """
3874+ return set(unit_data.keys()).issuperset(set(self.required_keys))
3875+
3876+ def get_data(self):
3877+ """
3878+ Retrieve the relation data for each unit involved in a relation and,
3879+ if complete, store it in a list under `self[self.name]`. This
3880+ is automatically called when the RelationContext is instantiated.
3881+
3882+ The units are sorted lexographically first by the service ID, then by
3883+ the unit ID. Thus, if an interface has two other services, 'db:1'
3884+ and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
3885+ and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
3886+ set of data, the relation data for the units will be stored in the
3887+ order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
3888+
3889+ If you only care about a single unit on the relation, you can just
3890+ access it as `{{ interface[0]['key'] }}`. However, if you can at all
3891+ support multiple units on a relation, you should iterate over the list,
3892+ like::
3893+
3894+ {% for unit in interface -%}
3895+ {{ unit['key'] }}{% if not loop.last %},{% endif %}
3896+ {%- endfor %}
3897+
3898+ Note that since all sets of relation data from all related services and
3899+ units are in a single list, if you need to know which service or unit a
3900+ set of data came from, you'll need to extend this class to preserve
3901+ that information.
3902+ """
3903+ if not hookenv.relation_ids(self.name):
3904+ return
3905+
3906+ ns = self.setdefault(self.name, [])
3907+ for rid in sorted(hookenv.relation_ids(self.name)):
3908+ for unit in sorted(hookenv.related_units(rid)):
3909+ reldata = hookenv.relation_get(rid=rid, unit=unit)
3910+ if self._is_ready(reldata):
3911+ ns.append(reldata)
3912+
3913+ def provide_data(self):
3914+ """
3915+ Return data to be relation_set for this interface.
3916+ """
3917+ return {}
3918+
3919+
3920+class MysqlRelation(RelationContext):
3921+ """
3922+ Relation context for the `mysql` interface.
3923+
3924+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
3925+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
3926+ """
3927+ name = 'db'
3928+ interface = 'mysql'
3929+
3930+ def __init__(self, *args, **kwargs):
3931+ self.required_keys = ['host', 'user', 'password', 'database']
3932+ RelationContext.__init__(self, *args, **kwargs)
3933+
3934+
3935+class HttpRelation(RelationContext):
3936+ """
3937+ Relation context for the `http` interface.
3938+
3939+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
3940+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
3941+ """
3942+ name = 'website'
3943+ interface = 'http'
3944+
3945+ def __init__(self, *args, **kwargs):
3946+ self.required_keys = ['host', 'port']
3947+ RelationContext.__init__(self, *args, **kwargs)
3948+
3949+ def provide_data(self):
3950+ return {
3951+ 'host': hookenv.unit_get('private-address'),
3952+ 'port': 80,
3953+ }
3954+
3955+
3956+class RequiredConfig(dict):
3957+ """
3958+ Data context that loads config options with one or more mandatory options.
3959+
3960+ Once the required options have been changed from their default values, all
3961+ config options will be available, namespaced under `config` to prevent
3962+ potential naming conflicts (for example, between a config option and a
3963+ relation property).
3964+
3965+ :param list *args: List of options that must be changed from their default values.
3966+ """
3967+
3968+ def __init__(self, *args):
3969+ self.required_options = args
3970+ self['config'] = hookenv.config()
3971+ with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
3972+ self.config = yaml.load(fp).get('options', {})
3973+
3974+ def __bool__(self):
3975+ for option in self.required_options:
3976+ if option not in self['config']:
3977+ return False
3978+ current_value = self['config'][option]
3979+ default_value = self.config[option].get('default')
3980+ if current_value == default_value:
3981+ return False
3982+ if current_value in (None, '') and default_value in (None, ''):
3983+ return False
3984+ return True
3985+
3986+ def __nonzero__(self):
3987+ return self.__bool__()
3988+
3989+
3990+class StoredContext(dict):
3991+ """
3992+ A data context that always returns the data that it was first created with.
3993+
3994+ This is useful to do a one-time generation of things like passwords, that
3995+ will thereafter use the same value that was originally generated, instead
3996+ of generating a new value each time it is run.
3997+ """
3998+ def __init__(self, file_name, config_data):
3999+ """
4000+ If the file exists, populate `self` with the data from the file.
4001+ Otherwise, populate with the given data and persist it to the file.
4002+ """
4003+ if os.path.exists(file_name):
4004+ self.update(self.read_context(file_name))
4005+ else:
4006+ self.store_context(file_name, config_data)
4007+ self.update(config_data)
4008+
4009+ def store_context(self, file_name, config_data):
4010+ if not os.path.isabs(file_name):
4011+ file_name = os.path.join(hookenv.charm_dir(), file_name)
4012+ with open(file_name, 'w') as file_stream:
4013+ os.fchmod(file_stream.fileno(), 0o600)
4014+ yaml.dump(config_data, file_stream)
4015+
4016+ def read_context(self, file_name):
4017+ if not os.path.isabs(file_name):
4018+ file_name = os.path.join(hookenv.charm_dir(), file_name)
4019+ with open(file_name, 'r') as file_stream:
4020+ data = yaml.load(file_stream)
4021+ if not data:
4022+ raise OSError("%s is empty" % file_name)
4023+ return data
4024+
4025+
4026+class TemplateCallback(ManagerCallback):
4027+ """
4028+ Callback class that will render a Jinja2 template, for use as a ready
4029+ action.
4030+
4031+ :param str source: The template source file, relative to
4032+ `$CHARM_DIR/templates`
4033+
4034+ :param str target: The target to write the rendered template to (or None)
4035+ :param str owner: The owner of the rendered file
4036+ :param str group: The group of the rendered file
4037+ :param int perms: The permissions of the rendered file
4038+ :param partial on_change_action: functools partial to be executed when
4039+ rendered file changes
4040+ :param jinja2 loader template_loader: A jinja2 template loader
4041+
4042+ :return str: The rendered template
4043+ """
4044+ def __init__(self, source, target,
4045+ owner='root', group='root', perms=0o444,
4046+ on_change_action=None, template_loader=None):
4047+ self.source = source
4048+ self.target = target
4049+ self.owner = owner
4050+ self.group = group
4051+ self.perms = perms
4052+ self.on_change_action = on_change_action
4053+ self.template_loader = template_loader
4054+
4055+ def __call__(self, manager, service_name, event_name):
4056+ pre_checksum = ''
4057+ if self.on_change_action and os.path.isfile(self.target):
4058+ pre_checksum = host.file_hash(self.target)
4059+ service = manager.get_service(service_name)
4060+ context = {'ctx': {}}
4061+ for ctx in service.get('required_data', []):
4062+ context.update(ctx)
4063+ context['ctx'].update(ctx)
4064+
4065+ result = templating.render(self.source, self.target, context,
4066+ self.owner, self.group, self.perms,
4067+ template_loader=self.template_loader)
4068+ if self.on_change_action:
4069+ if pre_checksum == host.file_hash(self.target):
4070+ hookenv.log(
4071+ 'No change detected: {}'.format(self.target),
4072+ hookenv.DEBUG)
4073+ else:
4074+ self.on_change_action()
4075+
4076+ return result
4077+
4078+
4079+# Convenience aliases for templates
4080+render_template = template = TemplateCallback
4081diff --git a/hooks/charmhelpers/core/strutils.py b/hooks/charmhelpers/core/strutils.py
4082new file mode 100644
4083index 0000000..685dabd
4084--- /dev/null
4085+++ b/hooks/charmhelpers/core/strutils.py
4086@@ -0,0 +1,123 @@
4087+#!/usr/bin/env python
4088+# -*- coding: utf-8 -*-
4089+
4090+# Copyright 2014-2015 Canonical Limited.
4091+#
4092+# Licensed under the Apache License, Version 2.0 (the "License");
4093+# you may not use this file except in compliance with the License.
4094+# You may obtain a copy of the License at
4095+#
4096+# http://www.apache.org/licenses/LICENSE-2.0
4097+#
4098+# Unless required by applicable law or agreed to in writing, software
4099+# distributed under the License is distributed on an "AS IS" BASIS,
4100+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4101+# See the License for the specific language governing permissions and
4102+# limitations under the License.
4103+
4104+import six
4105+import re
4106+
4107+
4108+def bool_from_string(value):
4109+ """Interpret string value as boolean.
4110+
4111+ Returns True if value translates to True otherwise False.
4112+ """
4113+ if isinstance(value, six.string_types):
4114+ value = six.text_type(value)
4115+ else:
4116+ msg = "Unable to interpret non-string value '%s' as boolean" % (value)
4117+ raise ValueError(msg)
4118+
4119+ value = value.strip().lower()
4120+
4121+ if value in ['y', 'yes', 'true', 't', 'on']:
4122+ return True
4123+ elif value in ['n', 'no', 'false', 'f', 'off']:
4124+ return False
4125+
4126+ msg = "Unable to interpret string value '%s' as boolean" % (value)
4127+ raise ValueError(msg)
4128+
4129+
4130+def bytes_from_string(value):
4131+ """Interpret human readable string value as bytes.
4132+
4133+ Returns int
4134+ """
4135+ BYTE_POWER = {
4136+ 'K': 1,
4137+ 'KB': 1,
4138+ 'M': 2,
4139+ 'MB': 2,
4140+ 'G': 3,
4141+ 'GB': 3,
4142+ 'T': 4,
4143+ 'TB': 4,
4144+ 'P': 5,
4145+ 'PB': 5,
4146+ }
4147+ if isinstance(value, six.string_types):
4148+ value = six.text_type(value)
4149+ else:
4150+ msg = "Unable to interpret non-string value '%s' as boolean" % (value)
4151+ raise ValueError(msg)
4152+ matches = re.match("([0-9]+)([a-zA-Z]+)", value)
4153+ if not matches:
4154+ msg = "Unable to interpret string value '%s' as bytes" % (value)
4155+ raise ValueError(msg)
4156+ return int(matches.group(1)) * (1024 ** BYTE_POWER[matches.group(2)])
4157+
4158+
4159+class BasicStringComparator(object):
4160+ """Provides a class that will compare strings from an iterator type object.
4161+ Used to provide > and < comparisons on strings that may not necessarily be
4162+ alphanumerically ordered. e.g. OpenStack or Ubuntu releases AFTER the
4163+ z-wrap.
4164+ """
4165+
4166+ _list = None
4167+
4168+ def __init__(self, item):
4169+ if self._list is None:
4170+ raise Exception("Must define the _list in the class definition!")
4171+ try:
4172+ self.index = self._list.index(item)
4173+ except Exception:
4174+ raise KeyError("Item '{}' is not in list '{}'"
4175+ .format(item, self._list))
4176+
4177+ def __eq__(self, other):
4178+ assert isinstance(other, str) or isinstance(other, self.__class__)
4179+ return self.index == self._list.index(other)
4180+
4181+ def __ne__(self, other):
4182+ return not self.__eq__(other)
4183+
4184+ def __lt__(self, other):
4185+ assert isinstance(other, str) or isinstance(other, self.__class__)
4186+ return self.index < self._list.index(other)
4187+
4188+ def __ge__(self, other):
4189+ return not self.__lt__(other)
4190+
4191+ def __gt__(self, other):
4192+ assert isinstance(other, str) or isinstance(other, self.__class__)
4193+ return self.index > self._list.index(other)
4194+
4195+ def __le__(self, other):
4196+ return not self.__gt__(other)
4197+
4198+ def __str__(self):
4199+ """Always give back the item at the index so it can be used in
4200+ comparisons like:
4201+
4202+ s_mitaka = CompareOpenStack('mitaka')
4203+ s_newton = CompareOpenstack('newton')
4204+
4205+ assert s_newton > s_mitaka
4206+
4207+ @returns: <string>
4208+ """
4209+ return self._list[self.index]
4210diff --git a/hooks/charmhelpers/core/sysctl.py b/hooks/charmhelpers/core/sysctl.py
4211new file mode 100644
4212index 0000000..6e413e3
4213--- /dev/null
4214+++ b/hooks/charmhelpers/core/sysctl.py
4215@@ -0,0 +1,54 @@
4216+#!/usr/bin/env python
4217+# -*- coding: utf-8 -*-
4218+
4219+# Copyright 2014-2015 Canonical Limited.
4220+#
4221+# Licensed under the Apache License, Version 2.0 (the "License");
4222+# you may not use this file except in compliance with the License.
4223+# You may obtain a copy of the License at
4224+#
4225+# http://www.apache.org/licenses/LICENSE-2.0
4226+#
4227+# Unless required by applicable law or agreed to in writing, software
4228+# distributed under the License is distributed on an "AS IS" BASIS,
4229+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4230+# See the License for the specific language governing permissions and
4231+# limitations under the License.
4232+
4233+import yaml
4234+
4235+from subprocess import check_call
4236+
4237+from charmhelpers.core.hookenv import (
4238+ log,
4239+ DEBUG,
4240+ ERROR,
4241+)
4242+
4243+__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
4244+
4245+
4246+def create(sysctl_dict, sysctl_file):
4247+ """Creates a sysctl.conf file from a YAML associative array
4248+
4249+ :param sysctl_dict: a YAML-formatted string of sysctl options eg "{ 'kernel.max_pid': 1337 }"
4250+ :type sysctl_dict: str
4251+ :param sysctl_file: path to the sysctl file to be saved
4252+ :type sysctl_file: str or unicode
4253+ :returns: None
4254+ """
4255+ try:
4256+ sysctl_dict_parsed = yaml.safe_load(sysctl_dict)
4257+ except yaml.YAMLError:
4258+ log("Error parsing YAML sysctl_dict: {}".format(sysctl_dict),
4259+ level=ERROR)
4260+ return
4261+
4262+ with open(sysctl_file, "w") as fd:
4263+ for key, value in sysctl_dict_parsed.items():
4264+ fd.write("{}={}\n".format(key, value))
4265+
4266+ log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed),
4267+ level=DEBUG)
4268+
4269+ check_call(["sysctl", "-p", sysctl_file])
4270diff --git a/hooks/charmhelpers/core/templating.py b/hooks/charmhelpers/core/templating.py
4271new file mode 100644
4272index 0000000..7b801a3
4273--- /dev/null
4274+++ b/hooks/charmhelpers/core/templating.py
4275@@ -0,0 +1,84 @@
4276+# Copyright 2014-2015 Canonical Limited.
4277+#
4278+# Licensed under the Apache License, Version 2.0 (the "License");
4279+# you may not use this file except in compliance with the License.
4280+# You may obtain a copy of the License at
4281+#
4282+# http://www.apache.org/licenses/LICENSE-2.0
4283+#
4284+# Unless required by applicable law or agreed to in writing, software
4285+# distributed under the License is distributed on an "AS IS" BASIS,
4286+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4287+# See the License for the specific language governing permissions and
4288+# limitations under the License.
4289+
4290+import os
4291+import sys
4292+
4293+from charmhelpers.core import host
4294+from charmhelpers.core import hookenv
4295+
4296+
4297+def render(source, target, context, owner='root', group='root',
4298+ perms=0o444, templates_dir=None, encoding='UTF-8', template_loader=None):
4299+ """
4300+ Render a template.
4301+
4302+ The `source` path, if not absolute, is relative to the `templates_dir`.
4303+
4304+ The `target` path should be absolute. It can also be `None`, in which
4305+ case no file will be written.
4306+
4307+ The context should be a dict containing the values to be replaced in the
4308+ template.
4309+
4310+ The `owner`, `group`, and `perms` options will be passed to `write_file`.
4311+
4312+ If omitted, `templates_dir` defaults to the `templates` folder in the charm.
4313+
4314+ The rendered template will be written to the file as well as being returned
4315+ as a string.
4316+
4317+ Note: Using this requires python-jinja2 or python3-jinja2; if it is not
4318+ installed, calling this will attempt to use charmhelpers.fetch.apt_install
4319+ to install it.
4320+ """
4321+ try:
4322+ from jinja2 import FileSystemLoader, Environment, exceptions
4323+ except ImportError:
4324+ try:
4325+ from charmhelpers.fetch import apt_install
4326+ except ImportError:
4327+ hookenv.log('Could not import jinja2, and could not import '
4328+ 'charmhelpers.fetch to install it',
4329+ level=hookenv.ERROR)
4330+ raise
4331+ if sys.version_info.major == 2:
4332+ apt_install('python-jinja2', fatal=True)
4333+ else:
4334+ apt_install('python3-jinja2', fatal=True)
4335+ from jinja2 import FileSystemLoader, Environment, exceptions
4336+
4337+ if template_loader:
4338+ template_env = Environment(loader=template_loader)
4339+ else:
4340+ if templates_dir is None:
4341+ templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
4342+ template_env = Environment(loader=FileSystemLoader(templates_dir))
4343+ try:
4344+ source = source
4345+ template = template_env.get_template(source)
4346+ except exceptions.TemplateNotFound as e:
4347+ hookenv.log('Could not load template %s from %s.' %
4348+ (source, templates_dir),
4349+ level=hookenv.ERROR)
4350+ raise e
4351+ content = template.render(context)
4352+ if target is not None:
4353+ target_dir = os.path.dirname(target)
4354+ if not os.path.exists(target_dir):
4355+ # This is a terrible default directory permission, as the file
4356+ # or its siblings will often contain secrets.
4357+ host.mkdir(os.path.dirname(target), owner, group, perms=0o755)
4358+ host.write_file(target, content.encode(encoding), owner, group, perms)
4359+ return content
4360diff --git a/hooks/charmhelpers/core/unitdata.py b/hooks/charmhelpers/core/unitdata.py
4361new file mode 100644
4362index 0000000..54ec969
4363--- /dev/null
4364+++ b/hooks/charmhelpers/core/unitdata.py
4365@@ -0,0 +1,518 @@
4366+#!/usr/bin/env python
4367+# -*- coding: utf-8 -*-
4368+#
4369+# Copyright 2014-2015 Canonical Limited.
4370+#
4371+# Licensed under the Apache License, Version 2.0 (the "License");
4372+# you may not use this file except in compliance with the License.
4373+# You may obtain a copy of the License at
4374+#
4375+# http://www.apache.org/licenses/LICENSE-2.0
4376+#
4377+# Unless required by applicable law or agreed to in writing, software
4378+# distributed under the License is distributed on an "AS IS" BASIS,
4379+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4380+# See the License for the specific language governing permissions and
4381+# limitations under the License.
4382+#
4383+# Authors:
4384+# Kapil Thangavelu <kapil.foss@gmail.com>
4385+#
4386+"""
4387+Intro
4388+-----
4389+
4390+A simple way to store state in units. This provides a key value
4391+storage with support for versioned, transactional operation,
4392+and can calculate deltas from previous values to simplify unit logic
4393+when processing changes.
4394+
4395+
4396+Hook Integration
4397+----------------
4398+
4399+There are several extant frameworks for hook execution, including
4400+
4401+ - charmhelpers.core.hookenv.Hooks
4402+ - charmhelpers.core.services.ServiceManager
4403+
4404+The storage classes are framework agnostic, one simple integration is
4405+via the HookData contextmanager. It will record the current hook
4406+execution environment (including relation data, config data, etc.),
4407+setup a transaction and allow easy access to the changes from
4408+previously seen values. One consequence of the integration is the
4409+reservation of particular keys ('rels', 'unit', 'env', 'config',
4410+'charm_revisions') for their respective values.
4411+
4412+Here's a fully worked integration example using hookenv.Hooks::
4413+
4414+ from charmhelper.core import hookenv, unitdata
4415+
4416+ hook_data = unitdata.HookData()
4417+ db = unitdata.kv()
4418+ hooks = hookenv.Hooks()
4419+
4420+ @hooks.hook
4421+ def config_changed():
4422+ # Print all changes to configuration from previously seen
4423+ # values.
4424+ for changed, (prev, cur) in hook_data.conf.items():
4425+ print('config changed', changed,
4426+ 'previous value', prev,
4427+ 'current value', cur)
4428+
4429+ # Get some unit specific bookeeping
4430+ if not db.get('pkg_key'):
4431+ key = urllib.urlopen('https://example.com/pkg_key').read()
4432+ db.set('pkg_key', key)
4433+
4434+ # Directly access all charm config as a mapping.
4435+ conf = db.getrange('config', True)
4436+
4437+ # Directly access all relation data as a mapping
4438+ rels = db.getrange('rels', True)
4439+
4440+ if __name__ == '__main__':
4441+ with hook_data():
4442+ hook.execute()
4443+
4444+
4445+A more basic integration is via the hook_scope context manager which simply
4446+manages transaction scope (and records hook name, and timestamp)::
4447+
4448+ >>> from unitdata import kv
4449+ >>> db = kv()
4450+ >>> with db.hook_scope('install'):
4451+ ... # do work, in transactional scope.
4452+ ... db.set('x', 1)
4453+ >>> db.get('x')
4454+ 1
4455+
4456+
4457+Usage
4458+-----
4459+
4460+Values are automatically json de/serialized to preserve basic typing
4461+and complex data struct capabilities (dicts, lists, ints, booleans, etc).
4462+
4463+Individual values can be manipulated via get/set::
4464+
4465+ >>> kv.set('y', True)
4466+ >>> kv.get('y')
4467+ True
4468+
4469+ # We can set complex values (dicts, lists) as a single key.
4470+ >>> kv.set('config', {'a': 1, 'b': True'})
4471+
4472+ # Also supports returning dictionaries as a record which
4473+ # provides attribute access.
4474+ >>> config = kv.get('config', record=True)
4475+ >>> config.b
4476+ True
4477+
4478+
4479+Groups of keys can be manipulated with update/getrange::
4480+
4481+ >>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
4482+ >>> kv.getrange('gui.', strip=True)
4483+ {'z': 1, 'y': 2}
4484+
4485+When updating values, its very helpful to understand which values
4486+have actually changed and how have they changed. The storage
4487+provides a delta method to provide for this::
4488+
4489+ >>> data = {'debug': True, 'option': 2}
4490+ >>> delta = kv.delta(data, 'config.')
4491+ >>> delta.debug.previous
4492+ None
4493+ >>> delta.debug.current
4494+ True
4495+ >>> delta
4496+ {'debug': (None, True), 'option': (None, 2)}
4497+
4498+Note the delta method does not persist the actual change, it needs to
4499+be explicitly saved via 'update' method::
4500+
4501+ >>> kv.update(data, 'config.')
4502+
4503+Values modified in the context of a hook scope retain historical values
4504+associated to the hookname.
4505+
4506+ >>> with db.hook_scope('config-changed'):
4507+ ... db.set('x', 42)
4508+ >>> db.gethistory('x')
4509+ [(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
4510+ (2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
4511+
4512+"""
4513+
4514+import collections
4515+import contextlib
4516+import datetime
4517+import itertools
4518+import json
4519+import os
4520+import pprint
4521+import sqlite3
4522+import sys
4523+
4524+__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
4525+
4526+
4527+class Storage(object):
4528+ """Simple key value database for local unit state within charms.
4529+
4530+ Modifications are not persisted unless :meth:`flush` is called.
4531+
4532+ To support dicts, lists, integer, floats, and booleans values
4533+ are automatically json encoded/decoded.
4534+ """
4535+ def __init__(self, path=None):
4536+ self.db_path = path
4537+ if path is None:
4538+ if 'UNIT_STATE_DB' in os.environ:
4539+ self.db_path = os.environ['UNIT_STATE_DB']
4540+ else:
4541+ self.db_path = os.path.join(
4542+ os.environ.get('CHARM_DIR', ''), '.unit-state.db')
4543+ self.conn = sqlite3.connect('%s' % self.db_path)
4544+ self.cursor = self.conn.cursor()
4545+ self.revision = None
4546+ self._closed = False
4547+ self._init()
4548+
4549+ def close(self):
4550+ if self._closed:
4551+ return
4552+ self.flush(False)
4553+ self.cursor.close()
4554+ self.conn.close()
4555+ self._closed = True
4556+
4557+ def get(self, key, default=None, record=False):
4558+ self.cursor.execute('select data from kv where key=?', [key])
4559+ result = self.cursor.fetchone()
4560+ if not result:
4561+ return default
4562+ if record:
4563+ return Record(json.loads(result[0]))
4564+ return json.loads(result[0])
4565+
4566+ def getrange(self, key_prefix, strip=False):
4567+ """
4568+ Get a range of keys starting with a common prefix as a mapping of
4569+ keys to values.
4570+
4571+ :param str key_prefix: Common prefix among all keys
4572+ :param bool strip: Optionally strip the common prefix from the key
4573+ names in the returned dict
4574+ :return dict: A (possibly empty) dict of key-value mappings
4575+ """
4576+ self.cursor.execute("select key, data from kv where key like ?",
4577+ ['%s%%' % key_prefix])
4578+ result = self.cursor.fetchall()
4579+
4580+ if not result:
4581+ return {}
4582+ if not strip:
4583+ key_prefix = ''
4584+ return dict([
4585+ (k[len(key_prefix):], json.loads(v)) for k, v in result])
4586+
4587+ def update(self, mapping, prefix=""):
4588+ """
4589+ Set the values of multiple keys at once.
4590+
4591+ :param dict mapping: Mapping of keys to values
4592+ :param str prefix: Optional prefix to apply to all keys in `mapping`
4593+ before setting
4594+ """
4595+ for k, v in mapping.items():
4596+ self.set("%s%s" % (prefix, k), v)
4597+
4598+ def unset(self, key):
4599+ """
4600+ Remove a key from the database entirely.
4601+ """
4602+ self.cursor.execute('delete from kv where key=?', [key])
4603+ if self.revision and self.cursor.rowcount:
4604+ self.cursor.execute(
4605+ 'insert into kv_revisions values (?, ?, ?)',
4606+ [key, self.revision, json.dumps('DELETED')])
4607+
4608+ def unsetrange(self, keys=None, prefix=""):
4609+ """
4610+ Remove a range of keys starting with a common prefix, from the database
4611+ entirely.
4612+
4613+ :param list keys: List of keys to remove.
4614+ :param str prefix: Optional prefix to apply to all keys in ``keys``
4615+ before removing.
4616+ """
4617+ if keys is not None:
4618+ keys = ['%s%s' % (prefix, key) for key in keys]
4619+ self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
4620+ if self.revision and self.cursor.rowcount:
4621+ self.cursor.execute(
4622+ 'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
4623+ list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
4624+ else:
4625+ self.cursor.execute('delete from kv where key like ?',
4626+ ['%s%%' % prefix])
4627+ if self.revision and self.cursor.rowcount:
4628+ self.cursor.execute(
4629+ 'insert into kv_revisions values (?, ?, ?)',
4630+ ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
4631+
4632+ def set(self, key, value):
4633+ """
4634+ Set a value in the database.
4635+
4636+ :param str key: Key to set the value for
4637+ :param value: Any JSON-serializable value to be set
4638+ """
4639+ serialized = json.dumps(value)
4640+
4641+ self.cursor.execute('select data from kv where key=?', [key])
4642+ exists = self.cursor.fetchone()
4643+
4644+ # Skip mutations to the same value
4645+ if exists:
4646+ if exists[0] == serialized:
4647+ return value
4648+
4649+ if not exists:
4650+ self.cursor.execute(
4651+ 'insert into kv (key, data) values (?, ?)',
4652+ (key, serialized))
4653+ else:
4654+ self.cursor.execute('''
4655+ update kv
4656+ set data = ?
4657+ where key = ?''', [serialized, key])
4658+
4659+ # Save
4660+ if not self.revision:
4661+ return value
4662+
4663+ self.cursor.execute(
4664+ 'select 1 from kv_revisions where key=? and revision=?',
4665+ [key, self.revision])
4666+ exists = self.cursor.fetchone()
4667+
4668+ if not exists:
4669+ self.cursor.execute(
4670+ '''insert into kv_revisions (
4671+ revision, key, data) values (?, ?, ?)''',
4672+ (self.revision, key, serialized))
4673+ else:
4674+ self.cursor.execute(
4675+ '''
4676+ update kv_revisions
4677+ set data = ?
4678+ where key = ?
4679+ and revision = ?''',
4680+ [serialized, key, self.revision])
4681+
4682+ return value
4683+
4684+ def delta(self, mapping, prefix):
4685+ """
4686+ return a delta containing values that have changed.
4687+ """
4688+ previous = self.getrange(prefix, strip=True)
4689+ if not previous:
4690+ pk = set()
4691+ else:
4692+ pk = set(previous.keys())
4693+ ck = set(mapping.keys())
4694+ delta = DeltaSet()
4695+
4696+ # added
4697+ for k in ck.difference(pk):
4698+ delta[k] = Delta(None, mapping[k])
4699+
4700+ # removed
4701+ for k in pk.difference(ck):
4702+ delta[k] = Delta(previous[k], None)
4703+
4704+ # changed
4705+ for k in pk.intersection(ck):
4706+ c = mapping[k]
4707+ p = previous[k]
4708+ if c != p:
4709+ delta[k] = Delta(p, c)
4710+
4711+ return delta
4712+
4713+ @contextlib.contextmanager
4714+ def hook_scope(self, name=""):
4715+ """Scope all future interactions to the current hook execution
4716+ revision."""
4717+ assert not self.revision
4718+ self.cursor.execute(
4719+ 'insert into hooks (hook, date) values (?, ?)',
4720+ (name or sys.argv[0],
4721+ datetime.datetime.utcnow().isoformat()))
4722+ self.revision = self.cursor.lastrowid
4723+ try:
4724+ yield self.revision
4725+ self.revision = None
4726+ except:
4727+ self.flush(False)
4728+ self.revision = None
4729+ raise
4730+ else:
4731+ self.flush()
4732+
4733+ def flush(self, save=True):
4734+ if save:
4735+ self.conn.commit()
4736+ elif self._closed:
4737+ return
4738+ else:
4739+ self.conn.rollback()
4740+
4741+ def _init(self):
4742+ self.cursor.execute('''
4743+ create table if not exists kv (
4744+ key text,
4745+ data text,
4746+ primary key (key)
4747+ )''')
4748+ self.cursor.execute('''
4749+ create table if not exists kv_revisions (
4750+ key text,
4751+ revision integer,
4752+ data text,
4753+ primary key (key, revision)
4754+ )''')
4755+ self.cursor.execute('''
4756+ create table if not exists hooks (
4757+ version integer primary key autoincrement,
4758+ hook text,
4759+ date text
4760+ )''')
4761+ self.conn.commit()
4762+
4763+ def gethistory(self, key, deserialize=False):
4764+ self.cursor.execute(
4765+ '''
4766+ select kv.revision, kv.key, kv.data, h.hook, h.date
4767+ from kv_revisions kv,
4768+ hooks h
4769+ where kv.key=?
4770+ and kv.revision = h.version
4771+ ''', [key])
4772+ if deserialize is False:
4773+ return self.cursor.fetchall()
4774+ return map(_parse_history, self.cursor.fetchall())
4775+
4776+ def debug(self, fh=sys.stderr):
4777+ self.cursor.execute('select * from kv')
4778+ pprint.pprint(self.cursor.fetchall(), stream=fh)
4779+ self.cursor.execute('select * from kv_revisions')
4780+ pprint.pprint(self.cursor.fetchall(), stream=fh)
4781+
4782+
4783+def _parse_history(d):
4784+ return (d[0], d[1], json.loads(d[2]), d[3],
4785+ datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
4786+
4787+
4788+class HookData(object):
4789+ """Simple integration for existing hook exec frameworks.
4790+
4791+ Records all unit information, and stores deltas for processing
4792+ by the hook.
4793+
4794+ Sample::
4795+
4796+ from charmhelper.core import hookenv, unitdata
4797+
4798+ changes = unitdata.HookData()
4799+ db = unitdata.kv()
4800+ hooks = hookenv.Hooks()
4801+
4802+ @hooks.hook
4803+ def config_changed():
4804+ # View all changes to configuration
4805+ for changed, (prev, cur) in changes.conf.items():
4806+ print('config changed', changed,
4807+ 'previous value', prev,
4808+ 'current value', cur)
4809+
4810+ # Get some unit specific bookeeping
4811+ if not db.get('pkg_key'):
4812+ key = urllib.urlopen('https://example.com/pkg_key').read()
4813+ db.set('pkg_key', key)
4814+
4815+ if __name__ == '__main__':
4816+ with changes():
4817+ hook.execute()
4818+
4819+ """
4820+ def __init__(self):
4821+ self.kv = kv()
4822+ self.conf = None
4823+ self.rels = None
4824+
4825+ @contextlib.contextmanager
4826+ def __call__(self):
4827+ from charmhelpers.core import hookenv
4828+ hook_name = hookenv.hook_name()
4829+
4830+ with self.kv.hook_scope(hook_name):
4831+ self._record_charm_version(hookenv.charm_dir())
4832+ delta_config, delta_relation = self._record_hook(hookenv)
4833+ yield self.kv, delta_config, delta_relation
4834+
4835+ def _record_charm_version(self, charm_dir):
4836+ # Record revisions.. charm revisions are meaningless
4837+ # to charm authors as they don't control the revision.
4838+ # so logic dependnent on revision is not particularly
4839+ # useful, however it is useful for debugging analysis.
4840+ charm_rev = open(
4841+ os.path.join(charm_dir, 'revision')).read().strip()
4842+ charm_rev = charm_rev or '0'
4843+ revs = self.kv.get('charm_revisions', [])
4844+ if charm_rev not in revs:
4845+ revs.append(charm_rev.strip() or '0')
4846+ self.kv.set('charm_revisions', revs)
4847+
4848+ def _record_hook(self, hookenv):
4849+ data = hookenv.execution_environment()
4850+ self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
4851+ self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
4852+ self.kv.set('env', dict(data['env']))
4853+ self.kv.set('unit', data['unit'])
4854+ self.kv.set('relid', data.get('relid'))
4855+ return conf_delta, rels_delta
4856+
4857+
4858+class Record(dict):
4859+
4860+ __slots__ = ()
4861+
4862+ def __getattr__(self, k):
4863+ if k in self:
4864+ return self[k]
4865+ raise AttributeError(k)
4866+
4867+
4868+class DeltaSet(Record):
4869+
4870+ __slots__ = ()
4871+
4872+
4873+Delta = collections.namedtuple('Delta', ['previous', 'current'])
4874+
4875+
4876+_KV = None
4877+
4878+
4879+def kv():
4880+ global _KV
4881+ if _KV is None:
4882+ _KV = Storage()
4883+ return _KV
4884diff --git a/hooks/charmhelpers/fetch/__init__.py b/hooks/charmhelpers/fetch/__init__.py
4885new file mode 100644
4886index 0000000..ec5e0fe
4887--- /dev/null
4888+++ b/hooks/charmhelpers/fetch/__init__.py
4889@@ -0,0 +1,197 @@
4890+# Copyright 2014-2015 Canonical Limited.
4891+#
4892+# Licensed under the Apache License, Version 2.0 (the "License");
4893+# you may not use this file except in compliance with the License.
4894+# You may obtain a copy of the License at
4895+#
4896+# http://www.apache.org/licenses/LICENSE-2.0
4897+#
4898+# Unless required by applicable law or agreed to in writing, software
4899+# distributed under the License is distributed on an "AS IS" BASIS,
4900+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4901+# See the License for the specific language governing permissions and
4902+# limitations under the License.
4903+
4904+import importlib
4905+from charmhelpers.osplatform import get_platform
4906+from yaml import safe_load
4907+from charmhelpers.core.hookenv import (
4908+ config,
4909+ log,
4910+)
4911+
4912+import six
4913+if six.PY3:
4914+ from urllib.parse import urlparse, urlunparse
4915+else:
4916+ from urlparse import urlparse, urlunparse
4917+
4918+
4919+# The order of this list is very important. Handlers should be listed in from
4920+# least- to most-specific URL matching.
4921+FETCH_HANDLERS = (
4922+ 'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
4923+ 'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
4924+ 'charmhelpers.fetch.giturl.GitUrlFetchHandler',
4925+)
4926+
4927+
4928+class SourceConfigError(Exception):
4929+ pass
4930+
4931+
4932+class UnhandledSource(Exception):
4933+ pass
4934+
4935+
4936+class AptLockError(Exception):
4937+ pass
4938+
4939+
4940+class BaseFetchHandler(object):
4941+
4942+ """Base class for FetchHandler implementations in fetch plugins"""
4943+
4944+ def can_handle(self, source):
4945+ """Returns True if the source can be handled. Otherwise returns
4946+ a string explaining why it cannot"""
4947+ return "Wrong source type"
4948+
4949+ def install(self, source):
4950+ """Try to download and unpack the source. Return the path to the
4951+ unpacked files or raise UnhandledSource."""
4952+ raise UnhandledSource("Wrong source type {}".format(source))
4953+
4954+ def parse_url(self, url):
4955+ return urlparse(url)
4956+
4957+ def base_url(self, url):
4958+ """Return url without querystring or fragment"""
4959+ parts = list(self.parse_url(url))
4960+ parts[4:] = ['' for i in parts[4:]]
4961+ return urlunparse(parts)
4962+
4963+
4964+__platform__ = get_platform()
4965+module = "charmhelpers.fetch.%s" % __platform__
4966+fetch = importlib.import_module(module)
4967+
4968+filter_installed_packages = fetch.filter_installed_packages
4969+install = fetch.install
4970+upgrade = fetch.upgrade
4971+update = fetch.update
4972+purge = fetch.purge
4973+add_source = fetch.add_source
4974+
4975+if __platform__ == "ubuntu":
4976+ apt_cache = fetch.apt_cache
4977+ apt_install = fetch.install
4978+ apt_update = fetch.update
4979+ apt_upgrade = fetch.upgrade
4980+ apt_purge = fetch.purge
4981+ apt_mark = fetch.apt_mark
4982+ apt_hold = fetch.apt_hold
4983+ apt_unhold = fetch.apt_unhold
4984+ get_upstream_version = fetch.get_upstream_version
4985+elif __platform__ == "centos":
4986+ yum_search = fetch.yum_search
4987+
4988+
4989+def configure_sources(update=False,
4990+ sources_var='install_sources',
4991+ keys_var='install_keys'):
4992+ """Configure multiple sources from charm configuration.
4993+
4994+ The lists are encoded as yaml fragments in the configuration.
4995+ The fragment needs to be included as a string. Sources and their
4996+ corresponding keys are of the types supported by add_source().
4997+
4998+ Example config:
4999+ install_sources: |
5000+ - "ppa:foo"
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches