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
diff --git a/.gitignore b/.gitignore
0new file mode 1006440new file mode 100644
index 0000000..021454b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
1*.pyc
2*~
3*.swp
diff --git a/README.md b/README.md
0new file mode 1006444new file mode 100644
index 0000000..0337c83
--- /dev/null
+++ b/README.md
@@ -0,0 +1,221 @@
1# Overview
2
3This is the base layer for all charms [built using layers][building]. It
4provides all of the standard Juju hooks and runs the
5[charms.reactive.main][charms.reactive] loop for them. It also bootstraps the
6[charm-helpers][] and [charms.reactive][] libraries and all of their
7dependencies for use by the charm.
8
9# Usage
10
11To create a charm layer using this base layer, you need only include it in
12a `layer.yaml` file:
13
14```yaml
15includes: ['layer:basic']
16```
17
18This will fetch this layer from [interfaces.juju.solutions][] and incorporate
19it into your charm layer. You can then add handlers under the `reactive/`
20directory. Note that **any** file under `reactive/` will be expected to
21contain handlers, whether as Python decorated functions or [executables][non-python]
22using the [external handler protocol][].
23
24### Charm Dependencies
25
26Each layer can include a `wheelhouse.txt` file with Python requirement lines.
27For example, this layer's `wheelhouse.txt` includes:
28
29```
30pip>=7.0.0,<8.0.0
31charmhelpers>=0.4.0,<1.0.0
32charms.reactive>=0.1.0,<2.0.0
33```
34
35All of these dependencies from each layer will be fetched (and updated) at build
36time and will be automatically installed by this base layer before any reactive
37handlers are run.
38
39Note that the `wheelhouse.txt` file is intended for **charm** dependencies only.
40That is, for libraries that the charm code itself needs to do its job of deploying
41and configuring the payload. If the payload itself has Python dependencies, those
42should be handled separately, by the charm.
43
44See [PyPI][pypi charms.X] for packages under the `charms.` namespace which might
45be useful for your charm.
46
47### Layer Namespace
48
49Each layer has a reserved section in the `charms.layer.` Python package namespace,
50which it can populate by including a `lib/charms/layer/<layer-name>.py` file or
51by placing files under `lib/charms/layer/<layer-name>/`. (If the layer name
52includes hyphens, replace them with underscores.) These can be helpers that the
53layer uses internally, or it can expose classes or functions to be used by other
54layers to interact with that layer.
55
56For example, a layer named `foo` could include a `lib/charms/layer/foo.py` file
57with some helper functions that other layers could access using:
58
59```python
60from charms.layer.foo import my_helper
61```
62
63### Layer Options
64
65Any layer can define options in its `layer.yaml`. Those options can then be set
66by other layers to change the behavior of your layer. The options are defined
67using [jsonschema][], which is the same way that [action paramters][] are defined.
68
69For example, the `foo` layer could include the following option definitons:
70
71```yaml
72includes: ['layer:basic']
73defines: # define some options for this layer (the layer "foo")
74 enable-bar: # define an "enable-bar" option for this layer
75 description: If true, enable support for "bar".
76 type: boolean
77 default: false
78```
79
80A layer using `foo` could then set it:
81
82```yaml
83includes: ['layer:foo']
84options:
85 foo: # setting options for the "foo" layer
86 enable-bar: true # set the "enable-bar" option to true
87```
88
89The `foo` layer can then use the `charms.layer.options` helper to load the values
90for the options that it defined. For example:
91
92```python
93from charms import layer
94
95@when('state')
96def do_thing():
97 layer_opts = layer.options('foo') # load all of the options for the "foo" layer
98 if layer_opts['enable-bar']: # check the value of the "enable-bar" option
99 hookenv.log("Bar is enabled")
100```
101
102You can also access layer options in other handlers, such as Bash, using
103the command-line interface:
104
105```bash
106. charms.reactive.sh
107
108@when 'state'
109function do_thing() {
110 if layer_option foo enable-bar; then
111 juju-log "Bar is enabled"
112 juju-log "bar-value is: $(layer_option foo bar-value)"
113 fi
114}
115
116reactive_handler_main
117```
118
119Note that options of type `boolean` will set the exit code, while other types
120will be printed out.
121
122# Hooks
123
124This layer provides hooks that other layers can react to using the decorators
125of the [charms.reactive][] library:
126
127 * `config-changed`
128 * `install`
129 * `leader-elected`
130 * `leader-settings-changed`
131 * `start`
132 * `stop`
133 * `upgrade-charm`
134 * `update-status`
135
136Other hooks are not implemented at this time. A new layer can implement storage
137or relation hooks in their own layer by putting them in the `hooks` directory.
138
139**Note:** Because `update-status` is invoked every 5 minutes, you should take
140care to ensure that your reactive handlers only invoke expensive operations
141when absolutely necessary. It is recommended that you use helpers like
142[`@only_once`][], [`@when_file_changed`][], and [`data_changed`][] to ensure
143that handlers run only when necessary.
144
145# Layer Configuration
146
147This layer supports the following options, which can be set in `layer.yaml`:
148
149 * **packages** A list of system packages to be installed before the reactive
150 handlers are invoked.
151
152 * **use_venv** If set to true, the charm dependencies from the various
153 layers' `wheelhouse.txt` files will be installed in a Python virtualenv
154 located at `$CHARM_DIR/../.venv`. This keeps charm dependencies from
155 conflicting with payload dependencies, but you must take care to preserve
156 the environment and interpreter if using `execl` or `subprocess`.
157
158 * **include_system_packages** If set to true and using a venv, include
159 the `--system-site-packages` options to make system Python libraries
160 visible within the venv.
161
162An example `layer.yaml` using these options might be:
163
164```yaml
165includes: ['layer:basic']
166options:
167 basic:
168 packages: ['git']
169 use_venv: true
170 include_system_packages: true
171```
172
173
174# Reactive States
175
176This layer will set the following states:
177
178 * **`config.changed`** Any config option has changed from its previous value.
179 This state is cleared automatically at the end of each hook invocation.
180
181 * **`config.changed.<option>`** A specific config option has changed.
182 **`<option>`** will be replaced by the config option name from `config.yaml`.
183 This state is cleared automatically at the end of each hook invocation.
184
185 * **`config.set.<option>`** A specific config option has a True or non-empty
186 value set. **`<option>`** will be replaced by the config option name from
187 `config.yaml`. This state is cleared automatically at the end of each hook
188 invocation.
189
190 * **`config.default.<option>`** A specific config option is set to its
191 default value. **`<option>`** will be replaced by the config option name
192 from `config.yaml`. This state is cleared automatically at the end of
193 each hook invocation.
194
195An example using the config states would be:
196
197```python
198@when('config.changed.my-opt')
199def my_opt_changed():
200 update_config()
201 restart_service()
202```
203
204
205# Actions
206
207This layer currently does not define any actions.
208
209
210[building]: https://jujucharms.com/docs/devel/authors-charm-building
211[charm-helpers]: https://pythonhosted.org/charmhelpers/
212[charms.reactive]: https://pythonhosted.org/charms.reactive/
213[interfaces.juju.solutions]: http://interfaces.juju.solutions/
214[non-python]: https://pythonhosted.org/charms.reactive/#non-python-reactive-handlers
215[external handler protocol]: https://pythonhosted.org/charms.reactive/charms.reactive.bus.html#charms.reactive.bus.ExternalHandler
216[jsonschema]: http://json-schema.org/
217[action paramters]: https://jujucharms.com/docs/stable/authors-charm-actions
218[pypi charms.X]: https://pypi.python.org/pypi?%3Aaction=search&term=charms.&submit=search
219[`@only_once`]: https://pythonhosted.org/charms.reactive/charms.reactive.decorators.html#charms.reactive.decorators.only_once
220[`@when_file_changed`]: https://pythonhosted.org/charms.reactive/charms.reactive.decorators.html#charms.reactive.decorators.when_file_changed
221[`data_changed`]: https://pythonhosted.org/charms.reactive/charms.reactive.helpers.html#charms.reactive.helpers.data_changed
diff --git a/config.yaml b/config.yaml
0new file mode 100644222new file mode 100644
index 0000000..1e4601f
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,21 @@
1options:
2 revcache-repo:
3 type: string
4 description: git repo for revcache
5 default: "https://git.launchpad.net/~pwlars/revcache"
6 revcache-branch:
7 type: string
8 description: git branch for revcache
9 default: "start"
10 config-ssl-certificate:
11 type: string
12 description: base64 encoded ssl certificate file
13 default: ""
14 config-ssl-chain:
15 type: string
16 description: base64 encoded ssl chain file
17 default: ""
18 config-ssl-key:
19 type: string
20 description: base64 encoded ssl key file
21 default: ""
diff --git a/copyright b/copyright
0new file mode 10064422new file mode 100644
index 0000000..4aca5f0
--- /dev/null
+++ b/copyright
@@ -0,0 +1,9 @@
1Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0
2
3Files: *
4Copyright: 2018, Canonical Ltd.
5License: GPL-3
6
7License: GPL-3
8 On Debian GNU/Linux system you can find the complete text of the
9 GPL-3 license in '/usr/share/common-licenses/GPL-3'
diff --git a/hooks/charmhelpers/__init__.py b/hooks/charmhelpers/__init__.py
0new file mode 10064410new file mode 100644
index 0000000..4886788
--- /dev/null
+++ b/hooks/charmhelpers/__init__.py
@@ -0,0 +1,36 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15# Bootstrap charm-helpers, installing its dependencies if necessary using
16# only standard libraries.
17import subprocess
18import sys
19
20try:
21 import six # flake8: noqa
22except ImportError:
23 if sys.version_info.major == 2:
24 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
25 else:
26 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
27 import six # flake8: noqa
28
29try:
30 import yaml # flake8: noqa
31except ImportError:
32 if sys.version_info.major == 2:
33 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
34 else:
35 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
36 import yaml # flake8: noqa
diff --git a/hooks/charmhelpers/contrib/__init__.py b/hooks/charmhelpers/contrib/__init__.py
0new file mode 10064437new file mode 100644
index 0000000..d7567b8
--- /dev/null
+++ b/hooks/charmhelpers/contrib/__init__.py
@@ -0,0 +1,13 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
diff --git a/hooks/charmhelpers/contrib/ansible/__init__.py b/hooks/charmhelpers/contrib/ansible/__init__.py
0new file mode 10064414new file mode 100644
index 0000000..f3617db
--- /dev/null
+++ b/hooks/charmhelpers/contrib/ansible/__init__.py
@@ -0,0 +1,252 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15# Copyright 2013 Canonical Ltd.
16#
17# Authors:
18# Charm Helpers Developers <juju@lists.ubuntu.com>
19"""Charm Helpers ansible - declare the state of your machines.
20
21This helper enables you to declare your machine state, rather than
22program it procedurally (and have to test each change to your procedures).
23Your install hook can be as simple as::
24
25 {{{
26 import charmhelpers.contrib.ansible
27
28
29 def install():
30 charmhelpers.contrib.ansible.install_ansible_support()
31 charmhelpers.contrib.ansible.apply_playbook('playbooks/install.yaml')
32 }}}
33
34and won't need to change (nor will its tests) when you change the machine
35state.
36
37All of your juju config and relation-data are available as template
38variables within your playbooks and templates. An install playbook looks
39something like::
40
41 {{{
42 ---
43 - hosts: localhost
44 user: root
45
46 tasks:
47 - name: Add private repositories.
48 template:
49 src: ../templates/private-repositories.list.jinja2
50 dest: /etc/apt/sources.list.d/private.list
51
52 - name: Update the cache.
53 apt: update_cache=yes
54
55 - name: Install dependencies.
56 apt: pkg={{ item }}
57 with_items:
58 - python-mimeparse
59 - python-webob
60 - sunburnt
61
62 - name: Setup groups.
63 group: name={{ item.name }} gid={{ item.gid }}
64 with_items:
65 - { name: 'deploy_user', gid: 1800 }
66 - { name: 'service_user', gid: 1500 }
67
68 ...
69 }}}
70
71Read more online about `playbooks`_ and standard ansible `modules`_.
72
73.. _playbooks: http://www.ansibleworks.com/docs/playbooks.html
74.. _modules: http://www.ansibleworks.com/docs/modules.html
75
76A further feature os the ansible hooks is to provide a light weight "action"
77scripting tool. This is a decorator that you apply to a function, and that
78function can now receive cli args, and can pass extra args to the playbook.
79
80e.g.
81
82
83@hooks.action()
84def some_action(amount, force="False"):
85 "Usage: some-action AMOUNT [force=True]" # <-- shown on error
86 # process the arguments
87 # do some calls
88 # return extra-vars to be passed to ansible-playbook
89 return {
90 'amount': int(amount),
91 'type': force,
92 }
93
94You can now create a symlink to hooks.py that can be invoked like a hook, but
95with cli params:
96
97# link actions/some-action to hooks/hooks.py
98
99actions/some-action amount=10 force=true
100
101"""
102import os
103import stat
104import subprocess
105import functools
106
107import charmhelpers.contrib.templating.contexts
108import charmhelpers.core.host
109import charmhelpers.core.hookenv
110import charmhelpers.fetch
111
112
113charm_dir = os.environ.get('CHARM_DIR', '')
114ansible_hosts_path = '/etc/ansible/hosts'
115# Ansible will automatically include any vars in the following
116# file in its inventory when run locally.
117ansible_vars_path = '/etc/ansible/host_vars/localhost'
118
119
120def install_ansible_support(from_ppa=True, ppa_location='ppa:rquillo/ansible'):
121 """Installs the ansible package.
122
123 By default it is installed from the `PPA`_ linked from
124 the ansible `website`_ or from a ppa specified by a charm config..
125
126 .. _PPA: https://launchpad.net/~rquillo/+archive/ansible
127 .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu
128
129 If from_ppa is empty, you must ensure that the package is available
130 from a configured repository.
131 """
132 if from_ppa:
133 charmhelpers.fetch.add_source(ppa_location)
134 charmhelpers.fetch.apt_update(fatal=True)
135 charmhelpers.fetch.apt_install('ansible')
136 with open(ansible_hosts_path, 'w+') as hosts_file:
137 hosts_file.write('localhost ansible_connection=local')
138
139
140def apply_playbook(playbook, tags=None, extra_vars=None):
141 tags = tags or []
142 tags = ",".join(tags)
143 charmhelpers.contrib.templating.contexts.juju_state_to_yaml(
144 ansible_vars_path, namespace_separator='__',
145 allow_hyphens_in_keys=False, mode=(stat.S_IRUSR | stat.S_IWUSR))
146
147 # we want ansible's log output to be unbuffered
148 env = os.environ.copy()
149 env['PYTHONUNBUFFERED'] = "1"
150 call = [
151 'ansible-playbook',
152 '-c',
153 'local',
154 playbook,
155 ]
156 if tags:
157 call.extend(['--tags', '{}'.format(tags)])
158 if extra_vars:
159 extra = ["%s=%s" % (k, v) for k, v in extra_vars.items()]
160 call.extend(['--extra-vars', " ".join(extra)])
161 subprocess.check_call(call, env=env)
162
163
164class AnsibleHooks(charmhelpers.core.hookenv.Hooks):
165 """Run a playbook with the hook-name as the tag.
166
167 This helper builds on the standard hookenv.Hooks helper,
168 but additionally runs the playbook with the hook-name specified
169 using --tags (ie. running all the tasks tagged with the hook-name).
170
171 Example::
172
173 hooks = AnsibleHooks(playbook_path='playbooks/my_machine_state.yaml')
174
175 # All the tasks within my_machine_state.yaml tagged with 'install'
176 # will be run automatically after do_custom_work()
177 @hooks.hook()
178 def install():
179 do_custom_work()
180
181 # For most of your hooks, you won't need to do anything other
182 # than run the tagged tasks for the hook:
183 @hooks.hook('config-changed', 'start', 'stop')
184 def just_use_playbook():
185 pass
186
187 # As a convenience, you can avoid the above noop function by specifying
188 # the hooks which are handled by ansible-only and they'll be registered
189 # for you:
190 # hooks = AnsibleHooks(
191 # 'playbooks/my_machine_state.yaml',
192 # default_hooks=['config-changed', 'start', 'stop'])
193
194 if __name__ == "__main__":
195 # execute a hook based on the name the program is called by
196 hooks.execute(sys.argv)
197
198 """
199
200 def __init__(self, playbook_path, default_hooks=None):
201 """Register any hooks handled by ansible."""
202 super(AnsibleHooks, self).__init__()
203
204 self._actions = {}
205 self.playbook_path = playbook_path
206
207 default_hooks = default_hooks or []
208
209 def noop(*args, **kwargs):
210 pass
211
212 for hook in default_hooks:
213 self.register(hook, noop)
214
215 def register_action(self, name, function):
216 """Register a hook"""
217 self._actions[name] = function
218
219 def execute(self, args):
220 """Execute the hook followed by the playbook using the hook as tag."""
221 hook_name = os.path.basename(args[0])
222 extra_vars = None
223 if hook_name in self._actions:
224 extra_vars = self._actions[hook_name](args[1:])
225 else:
226 super(AnsibleHooks, self).execute(args)
227
228 charmhelpers.contrib.ansible.apply_playbook(
229 self.playbook_path, tags=[hook_name], extra_vars=extra_vars)
230
231 def action(self, *action_names):
232 """Decorator, registering them as actions"""
233 def action_wrapper(decorated):
234
235 @functools.wraps(decorated)
236 def wrapper(argv):
237 kwargs = dict(arg.split('=') for arg in argv)
238 try:
239 return decorated(**kwargs)
240 except TypeError as e:
241 if decorated.__doc__:
242 e.args += (decorated.__doc__,)
243 raise
244
245 self.register_action(decorated.__name__, wrapper)
246 if '_' in decorated.__name__:
247 self.register_action(
248 decorated.__name__.replace('_', '-'), wrapper)
249
250 return wrapper
251
252 return action_wrapper
diff --git a/hooks/charmhelpers/contrib/templating/__init__.py b/hooks/charmhelpers/contrib/templating/__init__.py
0new file mode 100644253new file mode 100644
index 0000000..d7567b8
--- /dev/null
+++ b/hooks/charmhelpers/contrib/templating/__init__.py
@@ -0,0 +1,13 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
diff --git a/hooks/charmhelpers/contrib/templating/contexts.py b/hooks/charmhelpers/contrib/templating/contexts.py
0new file mode 10064414new file mode 100644
index 0000000..c1adf94
--- /dev/null
+++ b/hooks/charmhelpers/contrib/templating/contexts.py
@@ -0,0 +1,137 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15# Copyright 2013 Canonical Ltd.
16#
17# Authors:
18# Charm Helpers Developers <juju@lists.ubuntu.com>
19"""A helper to create a yaml cache of config with namespaced relation data."""
20import os
21import yaml
22
23import six
24
25import charmhelpers.core.hookenv
26
27
28charm_dir = os.environ.get('CHARM_DIR', '')
29
30
31def dict_keys_without_hyphens(a_dict):
32 """Return the a new dict with underscores instead of hyphens in keys."""
33 return dict(
34 (key.replace('-', '_'), val) for key, val in a_dict.items())
35
36
37def update_relations(context, namespace_separator=':'):
38 """Update the context with the relation data."""
39 # Add any relation data prefixed with the relation type.
40 relation_type = charmhelpers.core.hookenv.relation_type()
41 relations = []
42 context['current_relation'] = {}
43 if relation_type is not None:
44 relation_data = charmhelpers.core.hookenv.relation_get()
45 context['current_relation'] = relation_data
46 # Deprecated: the following use of relation data as keys
47 # directly in the context will be removed.
48 relation_data = dict(
49 ("{relation_type}{namespace_separator}{key}".format(
50 relation_type=relation_type,
51 key=key,
52 namespace_separator=namespace_separator), val)
53 for key, val in relation_data.items())
54 relation_data = dict_keys_without_hyphens(relation_data)
55 context.update(relation_data)
56 relations = charmhelpers.core.hookenv.relations_of_type(relation_type)
57 relations = [dict_keys_without_hyphens(rel) for rel in relations]
58
59 context['relations_full'] = charmhelpers.core.hookenv.relations()
60
61 # the hookenv.relations() data structure is effectively unusable in
62 # templates and other contexts when trying to access relation data other
63 # than the current relation. So provide a more useful structure that works
64 # with any hook.
65 local_unit = charmhelpers.core.hookenv.local_unit()
66 relations = {}
67 for rname, rids in context['relations_full'].items():
68 relations[rname] = []
69 for rid, rdata in rids.items():
70 data = rdata.copy()
71 if local_unit in rdata:
72 data.pop(local_unit)
73 for unit_name, rel_data in data.items():
74 new_data = {'__relid__': rid, '__unit__': unit_name}
75 new_data.update(rel_data)
76 relations[rname].append(new_data)
77 context['relations'] = relations
78
79
80def juju_state_to_yaml(yaml_path, namespace_separator=':',
81 allow_hyphens_in_keys=True, mode=None):
82 """Update the juju config and state in a yaml file.
83
84 This includes any current relation-get data, and the charm
85 directory.
86
87 This function was created for the ansible and saltstack
88 support, as those libraries can use a yaml file to supply
89 context to templates, but it may be useful generally to
90 create and update an on-disk cache of all the config, including
91 previous relation data.
92
93 By default, hyphens are allowed in keys as this is supported
94 by yaml, but for tools like ansible, hyphens are not valid [1].
95
96 [1] http://www.ansibleworks.com/docs/playbooks_variables.html#what-makes-a-valid-variable-name
97 """
98 config = charmhelpers.core.hookenv.config()
99
100 # Add the charm_dir which we will need to refer to charm
101 # file resources etc.
102 config['charm_dir'] = charm_dir
103 config['local_unit'] = charmhelpers.core.hookenv.local_unit()
104 config['unit_private_address'] = charmhelpers.core.hookenv.unit_private_ip()
105 config['unit_public_address'] = charmhelpers.core.hookenv.unit_get(
106 'public-address'
107 )
108
109 # Don't use non-standard tags for unicode which will not
110 # work when salt uses yaml.load_safe.
111 yaml.add_representer(six.text_type,
112 lambda dumper, value: dumper.represent_scalar(
113 six.u('tag:yaml.org,2002:str'), value))
114
115 yaml_dir = os.path.dirname(yaml_path)
116 if not os.path.exists(yaml_dir):
117 os.makedirs(yaml_dir)
118
119 if os.path.exists(yaml_path):
120 with open(yaml_path, "r") as existing_vars_file:
121 existing_vars = yaml.load(existing_vars_file.read())
122 else:
123 with open(yaml_path, "w+"):
124 pass
125 existing_vars = {}
126
127 if mode is not None:
128 os.chmod(yaml_path, mode)
129
130 if not allow_hyphens_in_keys:
131 config = dict_keys_without_hyphens(config)
132 existing_vars.update(config)
133
134 update_relations(existing_vars, namespace_separator)
135
136 with open(yaml_path, "w+") as fp:
137 fp.write(yaml.dump(existing_vars, default_flow_style=False))
diff --git a/hooks/charmhelpers/core/__init__.py b/hooks/charmhelpers/core/__init__.py
0new file mode 100644138new file mode 100644
index 0000000..d7567b8
--- /dev/null
+++ b/hooks/charmhelpers/core/__init__.py
@@ -0,0 +1,13 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
diff --git a/hooks/charmhelpers/core/decorators.py b/hooks/charmhelpers/core/decorators.py
0new file mode 10064414new file mode 100644
index 0000000..6ad41ee
--- /dev/null
+++ b/hooks/charmhelpers/core/decorators.py
@@ -0,0 +1,55 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15#
16# Copyright 2014 Canonical Ltd.
17#
18# Authors:
19# Edward Hope-Morley <opentastic@gmail.com>
20#
21
22import time
23
24from charmhelpers.core.hookenv import (
25 log,
26 INFO,
27)
28
29
30def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
31 """If the decorated function raises exception exc_type, allow num_retries
32 retry attempts before raise the exception.
33 """
34 def _retry_on_exception_inner_1(f):
35 def _retry_on_exception_inner_2(*args, **kwargs):
36 retries = num_retries
37 multiplier = 1
38 while True:
39 try:
40 return f(*args, **kwargs)
41 except exc_type:
42 if not retries:
43 raise
44
45 delay = base_delay * multiplier
46 multiplier += 1
47 log("Retrying '%s' %d more times (delay=%s)" %
48 (f.__name__, retries, delay), level=INFO)
49 retries -= 1
50 if delay:
51 time.sleep(delay)
52
53 return _retry_on_exception_inner_2
54
55 return _retry_on_exception_inner_1
diff --git a/hooks/charmhelpers/core/files.py b/hooks/charmhelpers/core/files.py
0new file mode 10064456new file mode 100644
index 0000000..fdd82b7
--- /dev/null
+++ b/hooks/charmhelpers/core/files.py
@@ -0,0 +1,43 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18__author__ = 'Jorge Niedbalski <niedbalski@ubuntu.com>'
19
20import os
21import subprocess
22
23
24def sed(filename, before, after, flags='g'):
25 """
26 Search and replaces the given pattern on filename.
27
28 :param filename: relative or absolute file path.
29 :param before: expression to be replaced (see 'man sed')
30 :param after: expression to replace with (see 'man sed')
31 :param flags: sed-compatible regex flags in example, to make
32 the search and replace case insensitive, specify ``flags="i"``.
33 The ``g`` flag is always specified regardless, so you do not
34 need to remember to include it when overriding this parameter.
35 :returns: If the sed command exit code was zero then return,
36 otherwise raise CalledProcessError.
37 """
38 expression = r's/{0}/{1}/{2}'.format(before,
39 after, flags)
40
41 return subprocess.check_call(["sed", "-i", "-r", "-e",
42 expression,
43 os.path.expanduser(filename)])
diff --git a/hooks/charmhelpers/core/fstab.py b/hooks/charmhelpers/core/fstab.py
0new file mode 10064444new file mode 100644
index 0000000..d9fa915
--- /dev/null
+++ b/hooks/charmhelpers/core/fstab.py
@@ -0,0 +1,132 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import io
19import os
20
21__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
22
23
24class Fstab(io.FileIO):
25 """This class extends file in order to implement a file reader/writer
26 for file `/etc/fstab`
27 """
28
29 class Entry(object):
30 """Entry class represents a non-comment line on the `/etc/fstab` file
31 """
32 def __init__(self, device, mountpoint, filesystem,
33 options, d=0, p=0):
34 self.device = device
35 self.mountpoint = mountpoint
36 self.filesystem = filesystem
37
38 if not options:
39 options = "defaults"
40
41 self.options = options
42 self.d = int(d)
43 self.p = int(p)
44
45 def __eq__(self, o):
46 return str(self) == str(o)
47
48 def __str__(self):
49 return "{} {} {} {} {} {}".format(self.device,
50 self.mountpoint,
51 self.filesystem,
52 self.options,
53 self.d,
54 self.p)
55
56 DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
57
58 def __init__(self, path=None):
59 if path:
60 self._path = path
61 else:
62 self._path = self.DEFAULT_PATH
63 super(Fstab, self).__init__(self._path, 'rb+')
64
65 def _hydrate_entry(self, line):
66 # NOTE: use split with no arguments to split on any
67 # whitespace including tabs
68 return Fstab.Entry(*filter(
69 lambda x: x not in ('', None),
70 line.strip("\n").split()))
71
72 @property
73 def entries(self):
74 self.seek(0)
75 for line in self.readlines():
76 line = line.decode('us-ascii')
77 try:
78 if line.strip() and not line.strip().startswith("#"):
79 yield self._hydrate_entry(line)
80 except ValueError:
81 pass
82
83 def get_entry_by_attr(self, attr, value):
84 for entry in self.entries:
85 e_attr = getattr(entry, attr)
86 if e_attr == value:
87 return entry
88 return None
89
90 def add_entry(self, entry):
91 if self.get_entry_by_attr('device', entry.device):
92 return False
93
94 self.write((str(entry) + '\n').encode('us-ascii'))
95 self.truncate()
96 return entry
97
98 def remove_entry(self, entry):
99 self.seek(0)
100
101 lines = [l.decode('us-ascii') for l in self.readlines()]
102
103 found = False
104 for index, line in enumerate(lines):
105 if line.strip() and not line.strip().startswith("#"):
106 if self._hydrate_entry(line) == entry:
107 found = True
108 break
109
110 if not found:
111 return False
112
113 lines.remove(line)
114
115 self.seek(0)
116 self.write(''.join(lines).encode('us-ascii'))
117 self.truncate()
118 return True
119
120 @classmethod
121 def remove_by_mountpoint(cls, mountpoint, path=None):
122 fstab = cls(path=path)
123 entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
124 if entry:
125 return fstab.remove_entry(entry)
126 return False
127
128 @classmethod
129 def add(cls, device, mountpoint, filesystem, options=None, path=None):
130 return cls(path=path).add_entry(Fstab.Entry(device,
131 mountpoint, filesystem,
132 options=options))
diff --git a/hooks/charmhelpers/core/hookenv.py b/hooks/charmhelpers/core/hookenv.py
0new file mode 100644133new file mode 100644
index 0000000..e44e22b
--- /dev/null
+++ b/hooks/charmhelpers/core/hookenv.py
@@ -0,0 +1,1068 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"Interactions with the Juju environment"
16# Copyright 2013 Canonical Ltd.
17#
18# Authors:
19# Charm Helpers Developers <juju@lists.ubuntu.com>
20
21from __future__ import print_function
22import copy
23from distutils.version import LooseVersion
24from functools import wraps
25import glob
26import os
27import json
28import yaml
29import subprocess
30import sys
31import errno
32import tempfile
33from subprocess import CalledProcessError
34
35import six
36if not six.PY3:
37 from UserDict import UserDict
38else:
39 from collections import UserDict
40
41CRITICAL = "CRITICAL"
42ERROR = "ERROR"
43WARNING = "WARNING"
44INFO = "INFO"
45DEBUG = "DEBUG"
46MARKER = object()
47
48cache = {}
49
50
51def cached(func):
52 """Cache return values for multiple executions of func + args
53
54 For example::
55
56 @cached
57 def unit_get(attribute):
58 pass
59
60 unit_get('test')
61
62 will cache the result of unit_get + 'test' for future calls.
63 """
64 @wraps(func)
65 def wrapper(*args, **kwargs):
66 global cache
67 key = str((func, args, kwargs))
68 try:
69 return cache[key]
70 except KeyError:
71 pass # Drop out of the exception handler scope.
72 res = func(*args, **kwargs)
73 cache[key] = res
74 return res
75 wrapper._wrapped = func
76 return wrapper
77
78
79def flush(key):
80 """Flushes any entries from function cache where the
81 key is found in the function+args """
82 flush_list = []
83 for item in cache:
84 if key in item:
85 flush_list.append(item)
86 for item in flush_list:
87 del cache[item]
88
89
90def log(message, level=None):
91 """Write a message to the juju log"""
92 command = ['juju-log']
93 if level:
94 command += ['-l', level]
95 if not isinstance(message, six.string_types):
96 message = repr(message)
97 command += [message]
98 # Missing juju-log should not cause failures in unit tests
99 # Send log output to stderr
100 try:
101 subprocess.call(command)
102 except OSError as e:
103 if e.errno == errno.ENOENT:
104 if level:
105 message = "{}: {}".format(level, message)
106 message = "juju-log: {}".format(message)
107 print(message, file=sys.stderr)
108 else:
109 raise
110
111
112class Serializable(UserDict):
113 """Wrapper, an object that can be serialized to yaml or json"""
114
115 def __init__(self, obj):
116 # wrap the object
117 UserDict.__init__(self)
118 self.data = obj
119
120 def __getattr__(self, attr):
121 # See if this object has attribute.
122 if attr in ("json", "yaml", "data"):
123 return self.__dict__[attr]
124 # Check for attribute in wrapped object.
125 got = getattr(self.data, attr, MARKER)
126 if got is not MARKER:
127 return got
128 # Proxy to the wrapped object via dict interface.
129 try:
130 return self.data[attr]
131 except KeyError:
132 raise AttributeError(attr)
133
134 def __getstate__(self):
135 # Pickle as a standard dictionary.
136 return self.data
137
138 def __setstate__(self, state):
139 # Unpickle into our wrapper.
140 self.data = state
141
142 def json(self):
143 """Serialize the object to json"""
144 return json.dumps(self.data)
145
146 def yaml(self):
147 """Serialize the object to yaml"""
148 return yaml.dump(self.data)
149
150
151def execution_environment():
152 """A convenient bundling of the current execution context"""
153 context = {}
154 context['conf'] = config()
155 if relation_id():
156 context['reltype'] = relation_type()
157 context['relid'] = relation_id()
158 context['rel'] = relation_get()
159 context['unit'] = local_unit()
160 context['rels'] = relations()
161 context['env'] = os.environ
162 return context
163
164
165def in_relation_hook():
166 """Determine whether we're running in a relation hook"""
167 return 'JUJU_RELATION' in os.environ
168
169
170def relation_type():
171 """The scope for the current relation hook"""
172 return os.environ.get('JUJU_RELATION', None)
173
174
175@cached
176def relation_id(relation_name=None, service_or_unit=None):
177 """The relation ID for the current or a specified relation"""
178 if not relation_name and not service_or_unit:
179 return os.environ.get('JUJU_RELATION_ID', None)
180 elif relation_name and service_or_unit:
181 service_name = service_or_unit.split('/')[0]
182 for relid in relation_ids(relation_name):
183 remote_service = remote_service_name(relid)
184 if remote_service == service_name:
185 return relid
186 else:
187 raise ValueError('Must specify neither or both of relation_name and service_or_unit')
188
189
190def local_unit():
191 """Local unit ID"""
192 return os.environ['JUJU_UNIT_NAME']
193
194
195def remote_unit():
196 """The remote unit for the current relation hook"""
197 return os.environ.get('JUJU_REMOTE_UNIT', None)
198
199
200def service_name():
201 """The name service group this unit belongs to"""
202 return local_unit().split('/')[0]
203
204
205@cached
206def remote_service_name(relid=None):
207 """The remote service name for a given relation-id (or the current relation)"""
208 if relid is None:
209 unit = remote_unit()
210 else:
211 units = related_units(relid)
212 unit = units[0] if units else None
213 return unit.split('/')[0] if unit else None
214
215
216def hook_name():
217 """The name of the currently executing hook"""
218 return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
219
220
221class Config(dict):
222 """A dictionary representation of the charm's config.yaml, with some
223 extra features:
224
225 - See which values in the dictionary have changed since the previous hook.
226 - For values that have changed, see what the previous value was.
227 - Store arbitrary data for use in a later hook.
228
229 NOTE: Do not instantiate this object directly - instead call
230 ``hookenv.config()``, which will return an instance of :class:`Config`.
231
232 Example usage::
233
234 >>> # inside a hook
235 >>> from charmhelpers.core import hookenv
236 >>> config = hookenv.config()
237 >>> config['foo']
238 'bar'
239 >>> # store a new key/value for later use
240 >>> config['mykey'] = 'myval'
241
242
243 >>> # user runs `juju set mycharm foo=baz`
244 >>> # now we're inside subsequent config-changed hook
245 >>> config = hookenv.config()
246 >>> config['foo']
247 'baz'
248 >>> # test to see if this val has changed since last hook
249 >>> config.changed('foo')
250 True
251 >>> # what was the previous value?
252 >>> config.previous('foo')
253 'bar'
254 >>> # keys/values that we add are preserved across hooks
255 >>> config['mykey']
256 'myval'
257
258 """
259 CONFIG_FILE_NAME = '.juju-persistent-config'
260
261 def __init__(self, *args, **kw):
262 super(Config, self).__init__(*args, **kw)
263 self.implicit_save = True
264 self._prev_dict = None
265 self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
266 if os.path.exists(self.path):
267 self.load_previous()
268 atexit(self._implicit_save)
269
270 def load_previous(self, path=None):
271 """Load previous copy of config from disk.
272
273 In normal usage you don't need to call this method directly - it
274 is called automatically at object initialization.
275
276 :param path:
277
278 File path from which to load the previous config. If `None`,
279 config is loaded from the default location. If `path` is
280 specified, subsequent `save()` calls will write to the same
281 path.
282
283 """
284 self.path = path or self.path
285 with open(self.path) as f:
286 self._prev_dict = json.load(f)
287 for k, v in copy.deepcopy(self._prev_dict).items():
288 if k not in self:
289 self[k] = v
290
291 def changed(self, key):
292 """Return True if the current value for this key is different from
293 the previous value.
294
295 """
296 if self._prev_dict is None:
297 return True
298 return self.previous(key) != self.get(key)
299
300 def previous(self, key):
301 """Return previous value for this key, or None if there
302 is no previous value.
303
304 """
305 if self._prev_dict:
306 return self._prev_dict.get(key)
307 return None
308
309 def save(self):
310 """Save this config to disk.
311
312 If the charm is using the :mod:`Services Framework <services.base>`
313 or :meth:'@hook <Hooks.hook>' decorator, this
314 is called automatically at the end of successful hook execution.
315 Otherwise, it should be called directly by user code.
316
317 To disable automatic saves, set ``implicit_save=False`` on this
318 instance.
319
320 """
321 with open(self.path, 'w') as f:
322 json.dump(self, f)
323
324 def _implicit_save(self):
325 if self.implicit_save:
326 self.save()
327
328
329@cached
330def config(scope=None):
331 """Juju charm configuration"""
332 config_cmd_line = ['config-get']
333 if scope is not None:
334 config_cmd_line.append(scope)
335 else:
336 config_cmd_line.append('--all')
337 config_cmd_line.append('--format=json')
338 try:
339 config_data = json.loads(
340 subprocess.check_output(config_cmd_line).decode('UTF-8'))
341 if scope is not None:
342 return config_data
343 return Config(config_data)
344 except ValueError:
345 return None
346
347
348@cached
349def relation_get(attribute=None, unit=None, rid=None):
350 """Get relation information"""
351 _args = ['relation-get', '--format=json']
352 if rid:
353 _args.append('-r')
354 _args.append(rid)
355 _args.append(attribute or '-')
356 if unit:
357 _args.append(unit)
358 try:
359 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
360 except ValueError:
361 return None
362 except CalledProcessError as e:
363 if e.returncode == 2:
364 return None
365 raise
366
367
368def relation_set(relation_id=None, relation_settings=None, **kwargs):
369 """Set relation information for the current unit"""
370 relation_settings = relation_settings if relation_settings else {}
371 relation_cmd_line = ['relation-set']
372 accepts_file = "--file" in subprocess.check_output(
373 relation_cmd_line + ["--help"], universal_newlines=True)
374 if relation_id is not None:
375 relation_cmd_line.extend(('-r', relation_id))
376 settings = relation_settings.copy()
377 settings.update(kwargs)
378 for key, value in settings.items():
379 # Force value to be a string: it always should, but some call
380 # sites pass in things like dicts or numbers.
381 if value is not None:
382 settings[key] = "{}".format(value)
383 if accepts_file:
384 # --file was introduced in Juju 1.23.2. Use it by default if
385 # available, since otherwise we'll break if the relation data is
386 # too big. Ideally we should tell relation-set to read the data from
387 # stdin, but that feature is broken in 1.23.2: Bug #1454678.
388 with tempfile.NamedTemporaryFile(delete=False) as settings_file:
389 settings_file.write(yaml.safe_dump(settings).encode("utf-8"))
390 subprocess.check_call(
391 relation_cmd_line + ["--file", settings_file.name])
392 os.remove(settings_file.name)
393 else:
394 for key, value in settings.items():
395 if value is None:
396 relation_cmd_line.append('{}='.format(key))
397 else:
398 relation_cmd_line.append('{}={}'.format(key, value))
399 subprocess.check_call(relation_cmd_line)
400 # Flush cache of any relation-gets for local unit
401 flush(local_unit())
402
403
404def relation_clear(r_id=None):
405 ''' Clears any relation data already set on relation r_id '''
406 settings = relation_get(rid=r_id,
407 unit=local_unit())
408 for setting in settings:
409 if setting not in ['public-address', 'private-address']:
410 settings[setting] = None
411 relation_set(relation_id=r_id,
412 **settings)
413
414
415@cached
416def relation_ids(reltype=None):
417 """A list of relation_ids"""
418 reltype = reltype or relation_type()
419 relid_cmd_line = ['relation-ids', '--format=json']
420 if reltype is not None:
421 relid_cmd_line.append(reltype)
422 return json.loads(
423 subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
424 return []
425
426
427@cached
428def related_units(relid=None):
429 """A list of related units"""
430 relid = relid or relation_id()
431 units_cmd_line = ['relation-list', '--format=json']
432 if relid is not None:
433 units_cmd_line.extend(('-r', relid))
434 return json.loads(
435 subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
436
437
438@cached
439def relation_for_unit(unit=None, rid=None):
440 """Get the json represenation of a unit's relation"""
441 unit = unit or remote_unit()
442 relation = relation_get(unit=unit, rid=rid)
443 for key in relation:
444 if key.endswith('-list'):
445 relation[key] = relation[key].split()
446 relation['__unit__'] = unit
447 return relation
448
449
450@cached
451def relations_for_id(relid=None):
452 """Get relations of a specific relation ID"""
453 relation_data = []
454 relid = relid or relation_ids()
455 for unit in related_units(relid):
456 unit_data = relation_for_unit(unit, relid)
457 unit_data['__relid__'] = relid
458 relation_data.append(unit_data)
459 return relation_data
460
461
462@cached
463def relations_of_type(reltype=None):
464 """Get relations of a specific type"""
465 relation_data = []
466 reltype = reltype or relation_type()
467 for relid in relation_ids(reltype):
468 for relation in relations_for_id(relid):
469 relation['__relid__'] = relid
470 relation_data.append(relation)
471 return relation_data
472
473
474@cached
475def metadata():
476 """Get the current charm metadata.yaml contents as a python object"""
477 with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
478 return yaml.safe_load(md)
479
480
481@cached
482def relation_types():
483 """Get a list of relation types supported by this charm"""
484 rel_types = []
485 md = metadata()
486 for key in ('provides', 'requires', 'peers'):
487 section = md.get(key)
488 if section:
489 rel_types.extend(section.keys())
490 return rel_types
491
492
493@cached
494def peer_relation_id():
495 '''Get the peers relation id if a peers relation has been joined, else None.'''
496 md = metadata()
497 section = md.get('peers')
498 if section:
499 for key in section:
500 relids = relation_ids(key)
501 if relids:
502 return relids[0]
503 return None
504
505
506@cached
507def relation_to_interface(relation_name):
508 """
509 Given the name of a relation, return the interface that relation uses.
510
511 :returns: The interface name, or ``None``.
512 """
513 return relation_to_role_and_interface(relation_name)[1]
514
515
516@cached
517def relation_to_role_and_interface(relation_name):
518 """
519 Given the name of a relation, return the role and the name of the interface
520 that relation uses (where role is one of ``provides``, ``requires``, or ``peers``).
521
522 :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
523 """
524 _metadata = metadata()
525 for role in ('provides', 'requires', 'peers'):
526 interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
527 if interface:
528 return role, interface
529 return None, None
530
531
532@cached
533def role_and_interface_to_relations(role, interface_name):
534 """
535 Given a role and interface name, return a list of relation names for the
536 current charm that use that interface under that role (where role is one
537 of ``provides``, ``requires``, or ``peers``).
538
539 :returns: A list of relation names.
540 """
541 _metadata = metadata()
542 results = []
543 for relation_name, relation in _metadata.get(role, {}).items():
544 if relation['interface'] == interface_name:
545 results.append(relation_name)
546 return results
547
548
549@cached
550def interface_to_relations(interface_name):
551 """
552 Given an interface, return a list of relation names for the current
553 charm that use that interface.
554
555 :returns: A list of relation names.
556 """
557 results = []
558 for role in ('provides', 'requires', 'peers'):
559 results.extend(role_and_interface_to_relations(role, interface_name))
560 return results
561
562
563@cached
564def charm_name():
565 """Get the name of the current charm as is specified on metadata.yaml"""
566 return metadata().get('name')
567
568
569@cached
570def relations():
571 """Get a nested dictionary of relation data for all related units"""
572 rels = {}
573 for reltype in relation_types():
574 relids = {}
575 for relid in relation_ids(reltype):
576 units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
577 for unit in related_units(relid):
578 reldata = relation_get(unit=unit, rid=relid)
579 units[unit] = reldata
580 relids[relid] = units
581 rels[reltype] = relids
582 return rels
583
584
585@cached
586def is_relation_made(relation, keys='private-address'):
587 '''
588 Determine whether a relation is established by checking for
589 presence of key(s). If a list of keys is provided, they
590 must all be present for the relation to be identified as made
591 '''
592 if isinstance(keys, str):
593 keys = [keys]
594 for r_id in relation_ids(relation):
595 for unit in related_units(r_id):
596 context = {}
597 for k in keys:
598 context[k] = relation_get(k, rid=r_id,
599 unit=unit)
600 if None not in context.values():
601 return True
602 return False
603
604
605def open_port(port, protocol="TCP"):
606 """Open a service network port"""
607 _args = ['open-port']
608 _args.append('{}/{}'.format(port, protocol))
609 subprocess.check_call(_args)
610
611
612def close_port(port, protocol="TCP"):
613 """Close a service network port"""
614 _args = ['close-port']
615 _args.append('{}/{}'.format(port, protocol))
616 subprocess.check_call(_args)
617
618
619def open_ports(start, end, protocol="TCP"):
620 """Opens a range of service network ports"""
621 _args = ['open-port']
622 _args.append('{}-{}/{}'.format(start, end, protocol))
623 subprocess.check_call(_args)
624
625
626def close_ports(start, end, protocol="TCP"):
627 """Close a range of service network ports"""
628 _args = ['close-port']
629 _args.append('{}-{}/{}'.format(start, end, protocol))
630 subprocess.check_call(_args)
631
632
633@cached
634def unit_get(attribute):
635 """Get the unit ID for the remote unit"""
636 _args = ['unit-get', '--format=json', attribute]
637 try:
638 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
639 except ValueError:
640 return None
641
642
643def unit_public_ip():
644 """Get this unit's public IP address"""
645 return unit_get('public-address')
646
647
648def unit_private_ip():
649 """Get this unit's private IP address"""
650 return unit_get('private-address')
651
652
653@cached
654def storage_get(attribute=None, storage_id=None):
655 """Get storage attributes"""
656 _args = ['storage-get', '--format=json']
657 if storage_id:
658 _args.extend(('-s', storage_id))
659 if attribute:
660 _args.append(attribute)
661 try:
662 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
663 except ValueError:
664 return None
665
666
667@cached
668def storage_list(storage_name=None):
669 """List the storage IDs for the unit"""
670 _args = ['storage-list', '--format=json']
671 if storage_name:
672 _args.append(storage_name)
673 try:
674 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
675 except ValueError:
676 return None
677 except OSError as e:
678 import errno
679 if e.errno == errno.ENOENT:
680 # storage-list does not exist
681 return []
682 raise
683
684
685class UnregisteredHookError(Exception):
686 """Raised when an undefined hook is called"""
687 pass
688
689
690class Hooks(object):
691 """A convenient handler for hook functions.
692
693 Example::
694
695 hooks = Hooks()
696
697 # register a hook, taking its name from the function name
698 @hooks.hook()
699 def install():
700 pass # your code here
701
702 # register a hook, providing a custom hook name
703 @hooks.hook("config-changed")
704 def config_changed():
705 pass # your code here
706
707 if __name__ == "__main__":
708 # execute a hook based on the name the program is called by
709 hooks.execute(sys.argv)
710 """
711
712 def __init__(self, config_save=None):
713 super(Hooks, self).__init__()
714 self._hooks = {}
715
716 # For unknown reasons, we allow the Hooks constructor to override
717 # config().implicit_save.
718 if config_save is not None:
719 config().implicit_save = config_save
720
721 def register(self, name, function):
722 """Register a hook"""
723 self._hooks[name] = function
724
725 def execute(self, args):
726 """Execute a registered hook based on args[0]"""
727 _run_atstart()
728 hook_name = os.path.basename(args[0])
729 if hook_name in self._hooks:
730 try:
731 self._hooks[hook_name]()
732 except SystemExit as x:
733 if x.code is None or x.code == 0:
734 _run_atexit()
735 raise
736 _run_atexit()
737 else:
738 raise UnregisteredHookError(hook_name)
739
740 def hook(self, *hook_names):
741 """Decorator, registering them as hooks"""
742 def wrapper(decorated):
743 for hook_name in hook_names:
744 self.register(hook_name, decorated)
745 else:
746 self.register(decorated.__name__, decorated)
747 if '_' in decorated.__name__:
748 self.register(
749 decorated.__name__.replace('_', '-'), decorated)
750 return decorated
751 return wrapper
752
753
754def charm_dir():
755 """Return the root directory of the current charm"""
756 return os.environ.get('CHARM_DIR')
757
758
759@cached
760def action_get(key=None):
761 """Gets the value of an action parameter, or all key/value param pairs"""
762 cmd = ['action-get']
763 if key is not None:
764 cmd.append(key)
765 cmd.append('--format=json')
766 action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
767 return action_data
768
769
770def action_set(values):
771 """Sets the values to be returned after the action finishes"""
772 cmd = ['action-set']
773 for k, v in list(values.items()):
774 cmd.append('{}={}'.format(k, v))
775 subprocess.check_call(cmd)
776
777
778def action_fail(message):
779 """Sets the action status to failed and sets the error message.
780
781 The results set by action_set are preserved."""
782 subprocess.check_call(['action-fail', message])
783
784
785def action_name():
786 """Get the name of the currently executing action."""
787 return os.environ.get('JUJU_ACTION_NAME')
788
789
790def action_uuid():
791 """Get the UUID of the currently executing action."""
792 return os.environ.get('JUJU_ACTION_UUID')
793
794
795def action_tag():
796 """Get the tag for the currently executing action."""
797 return os.environ.get('JUJU_ACTION_TAG')
798
799
800def status_set(workload_state, message):
801 """Set the workload state with a message
802
803 Use status-set to set the workload state with a message which is visible
804 to the user via juju status. If the status-set command is not found then
805 assume this is juju < 1.23 and juju-log the message unstead.
806
807 workload_state -- valid juju workload state.
808 message -- status update message
809 """
810 valid_states = ['maintenance', 'blocked', 'waiting', 'active']
811 if workload_state not in valid_states:
812 raise ValueError(
813 '{!r} is not a valid workload state'.format(workload_state)
814 )
815 cmd = ['status-set', workload_state, message]
816 try:
817 ret = subprocess.call(cmd)
818 if ret == 0:
819 return
820 except OSError as e:
821 if e.errno != errno.ENOENT:
822 raise
823 log_message = 'status-set failed: {} {}'.format(workload_state,
824 message)
825 log(log_message, level='INFO')
826
827
828def status_get():
829 """Retrieve the previously set juju workload state and message
830
831 If the status-get command is not found then assume this is juju < 1.23 and
832 return 'unknown', ""
833
834 """
835 cmd = ['status-get', "--format=json", "--include-data"]
836 try:
837 raw_status = subprocess.check_output(cmd)
838 except OSError as e:
839 if e.errno == errno.ENOENT:
840 return ('unknown', "")
841 else:
842 raise
843 else:
844 status = json.loads(raw_status.decode("UTF-8"))
845 return (status["status"], status["message"])
846
847
848def translate_exc(from_exc, to_exc):
849 def inner_translate_exc1(f):
850 @wraps(f)
851 def inner_translate_exc2(*args, **kwargs):
852 try:
853 return f(*args, **kwargs)
854 except from_exc:
855 raise to_exc
856
857 return inner_translate_exc2
858
859 return inner_translate_exc1
860
861
862def application_version_set(version):
863 """Charm authors may trigger this command from any hook to output what
864 version of the application is running. This could be a package version,
865 for instance postgres version 9.5. It could also be a build number or
866 version control revision identifier, for instance git sha 6fb7ba68. """
867
868 cmd = ['application-version-set']
869 cmd.append(version)
870 try:
871 subprocess.check_call(cmd)
872 except OSError:
873 log("Application Version: {}".format(version))
874
875
876@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
877def is_leader():
878 """Does the current unit hold the juju leadership
879
880 Uses juju to determine whether the current unit is the leader of its peers
881 """
882 cmd = ['is-leader', '--format=json']
883 return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
884
885
886@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
887def leader_get(attribute=None):
888 """Juju leader get value(s)"""
889 cmd = ['leader-get', '--format=json'] + [attribute or '-']
890 return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
891
892
893@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
894def leader_set(settings=None, **kwargs):
895 """Juju leader set value(s)"""
896 # Don't log secrets.
897 # log("Juju leader-set '%s'" % (settings), level=DEBUG)
898 cmd = ['leader-set']
899 settings = settings or {}
900 settings.update(kwargs)
901 for k, v in settings.items():
902 if v is None:
903 cmd.append('{}='.format(k))
904 else:
905 cmd.append('{}={}'.format(k, v))
906 subprocess.check_call(cmd)
907
908
909@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
910def payload_register(ptype, klass, pid):
911 """ is used while a hook is running to let Juju know that a
912 payload has been started."""
913 cmd = ['payload-register']
914 for x in [ptype, klass, pid]:
915 cmd.append(x)
916 subprocess.check_call(cmd)
917
918
919@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
920def payload_unregister(klass, pid):
921 """ is used while a hook is running to let Juju know
922 that a payload has been manually stopped. The <class> and <id> provided
923 must match a payload that has been previously registered with juju using
924 payload-register."""
925 cmd = ['payload-unregister']
926 for x in [klass, pid]:
927 cmd.append(x)
928 subprocess.check_call(cmd)
929
930
931@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
932def payload_status_set(klass, pid, status):
933 """is used to update the current status of a registered payload.
934 The <class> and <id> provided must match a payload that has been previously
935 registered with juju using payload-register. The <status> must be one of the
936 follow: starting, started, stopping, stopped"""
937 cmd = ['payload-status-set']
938 for x in [klass, pid, status]:
939 cmd.append(x)
940 subprocess.check_call(cmd)
941
942
943@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
944def resource_get(name):
945 """used to fetch the resource path of the given name.
946
947 <name> must match a name of defined resource in metadata.yaml
948
949 returns either a path or False if resource not available
950 """
951 if not name:
952 return False
953
954 cmd = ['resource-get', name]
955 try:
956 return subprocess.check_output(cmd).decode('UTF-8')
957 except subprocess.CalledProcessError:
958 return False
959
960
961@cached
962def juju_version():
963 """Full version string (eg. '1.23.3.1-trusty-amd64')"""
964 # Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
965 jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
966 return subprocess.check_output([jujud, 'version'],
967 universal_newlines=True).strip()
968
969
970@cached
971def has_juju_version(minimum_version):
972 """Return True if the Juju version is at least the provided version"""
973 return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
974
975
976_atexit = []
977_atstart = []
978
979
980def atstart(callback, *args, **kwargs):
981 '''Schedule a callback to run before the main hook.
982
983 Callbacks are run in the order they were added.
984
985 This is useful for modules and classes to perform initialization
986 and inject behavior. In particular:
987
988 - Run common code before all of your hooks, such as logging
989 the hook name or interesting relation data.
990 - Defer object or module initialization that requires a hook
991 context until we know there actually is a hook context,
992 making testing easier.
993 - Rather than requiring charm authors to include boilerplate to
994 invoke your helper's behavior, have it run automatically if
995 your object is instantiated or module imported.
996
997 This is not at all useful after your hook framework as been launched.
998 '''
999 global _atstart
1000 _atstart.append((callback, args, kwargs))
1001
1002
1003def atexit(callback, *args, **kwargs):
1004 '''Schedule a callback to run on successful hook completion.
1005
1006 Callbacks are run in the reverse order that they were added.'''
1007 _atexit.append((callback, args, kwargs))
1008
1009
1010def _run_atstart():
1011 '''Hook frameworks must invoke this before running the main hook body.'''
1012 global _atstart
1013 for callback, args, kwargs in _atstart:
1014 callback(*args, **kwargs)
1015 del _atstart[:]
1016
1017
1018def _run_atexit():
1019 '''Hook frameworks must invoke this after the main hook body has
1020 successfully completed. Do not invoke it if the hook fails.'''
1021 global _atexit
1022 for callback, args, kwargs in reversed(_atexit):
1023 callback(*args, **kwargs)
1024 del _atexit[:]
1025
1026
1027@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1028def network_get_primary_address(binding):
1029 '''
1030 Retrieve the primary network address for a named binding
1031
1032 :param binding: string. The name of a relation of extra-binding
1033 :return: string. The primary IP address for the named binding
1034 :raise: NotImplementedError if run on Juju < 2.0
1035 '''
1036 cmd = ['network-get', '--primary-address', binding]
1037 return subprocess.check_output(cmd).decode('UTF-8').strip()
1038
1039
1040def add_metric(*args, **kwargs):
1041 """Add metric values. Values may be expressed with keyword arguments. For
1042 metric names containing dashes, these may be expressed as one or more
1043 'key=value' positional arguments. May only be called from the collect-metrics
1044 hook."""
1045 _args = ['add-metric']
1046 _kvpairs = []
1047 _kvpairs.extend(args)
1048 _kvpairs.extend(['{}={}'.format(k, v) for k, v in kwargs.items()])
1049 _args.extend(sorted(_kvpairs))
1050 try:
1051 subprocess.check_call(_args)
1052 return
1053 except EnvironmentError as e:
1054 if e.errno != errno.ENOENT:
1055 raise
1056 log_message = 'add-metric failed: {}'.format(' '.join(_kvpairs))
1057 log(log_message, level='INFO')
1058
1059
1060def meter_status():
1061 """Get the meter status, if running in the meter-status-changed hook."""
1062 return os.environ.get('JUJU_METER_STATUS')
1063
1064
1065def meter_info():
1066 """Get the meter status information, if running in the meter-status-changed
1067 hook."""
1068 return os.environ.get('JUJU_METER_INFO')
diff --git a/hooks/charmhelpers/core/host.py b/hooks/charmhelpers/core/host.py
0new file mode 1006441069new file mode 100644
index 0000000..88e80a4
--- /dev/null
+++ b/hooks/charmhelpers/core/host.py
@@ -0,0 +1,922 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Tools for working with the host system"""
16# Copyright 2012 Canonical Ltd.
17#
18# Authors:
19# Nick Moffitt <nick.moffitt@canonical.com>
20# Matthew Wedgwood <matthew.wedgwood@canonical.com>
21
22import os
23import re
24import pwd
25import glob
26import grp
27import random
28import string
29import subprocess
30import hashlib
31import functools
32import itertools
33import six
34
35from contextlib import contextmanager
36from collections import OrderedDict
37from .hookenv import log
38from .fstab import Fstab
39from charmhelpers.osplatform import get_platform
40
41__platform__ = get_platform()
42if __platform__ == "ubuntu":
43 from charmhelpers.core.host_factory.ubuntu import (
44 service_available,
45 add_new_group,
46 lsb_release,
47 cmp_pkgrevno,
48 CompareHostReleases,
49 ) # flake8: noqa -- ignore F401 for this import
50elif __platform__ == "centos":
51 from charmhelpers.core.host_factory.centos import (
52 service_available,
53 add_new_group,
54 lsb_release,
55 cmp_pkgrevno,
56 CompareHostReleases,
57 ) # flake8: noqa -- ignore F401 for this import
58
59UPDATEDB_PATH = '/etc/updatedb.conf'
60
61def service_start(service_name, **kwargs):
62 """Start a system service.
63
64 The specified service name is managed via the system level init system.
65 Some init systems (e.g. upstart) require that additional arguments be
66 provided in order to directly control service instances whereas other init
67 systems allow for addressing instances of a service directly by name (e.g.
68 systemd).
69
70 The kwargs allow for the additional parameters to be passed to underlying
71 init systems for those systems which require/allow for them. For example,
72 the ceph-osd upstart script requires the id parameter to be passed along
73 in order to identify which running daemon should be reloaded. The follow-
74 ing example stops the ceph-osd service for instance id=4:
75
76 service_stop('ceph-osd', id=4)
77
78 :param service_name: the name of the service to stop
79 :param **kwargs: additional parameters to pass to the init system when
80 managing services. These will be passed as key=value
81 parameters to the init system's commandline. kwargs
82 are ignored for systemd enabled systems.
83 """
84 return service('start', service_name, **kwargs)
85
86
87def service_stop(service_name, **kwargs):
88 """Stop a system service.
89
90 The specified service name is managed via the system level init system.
91 Some init systems (e.g. upstart) require that additional arguments be
92 provided in order to directly control service instances whereas other init
93 systems allow for addressing instances of a service directly by name (e.g.
94 systemd).
95
96 The kwargs allow for the additional parameters to be passed to underlying
97 init systems for those systems which require/allow for them. For example,
98 the ceph-osd upstart script requires the id parameter to be passed along
99 in order to identify which running daemon should be reloaded. The follow-
100 ing example stops the ceph-osd service for instance id=4:
101
102 service_stop('ceph-osd', id=4)
103
104 :param service_name: the name of the service to stop
105 :param **kwargs: additional parameters to pass to the init system when
106 managing services. These will be passed as key=value
107 parameters to the init system's commandline. kwargs
108 are ignored for systemd enabled systems.
109 """
110 return service('stop', service_name, **kwargs)
111
112
113def service_restart(service_name, **kwargs):
114 """Restart a system service.
115
116 The specified service name is managed via the system level init system.
117 Some init systems (e.g. upstart) require that additional arguments be
118 provided in order to directly control service instances whereas other init
119 systems allow for addressing instances of a service directly by name (e.g.
120 systemd).
121
122 The kwargs allow for the additional parameters to be passed to underlying
123 init systems for those systems which require/allow for them. For example,
124 the ceph-osd upstart script requires the id parameter to be passed along
125 in order to identify which running daemon should be restarted. The follow-
126 ing example restarts the ceph-osd service for instance id=4:
127
128 service_restart('ceph-osd', id=4)
129
130 :param service_name: the name of the service to restart
131 :param **kwargs: additional parameters to pass to the init system when
132 managing services. These will be passed as key=value
133 parameters to the init system's commandline. kwargs
134 are ignored for init systems not allowing additional
135 parameters via the commandline (systemd).
136 """
137 return service('restart', service_name)
138
139
140def service_reload(service_name, restart_on_failure=False, **kwargs):
141 """Reload a system service, optionally falling back to restart if
142 reload fails.
143
144 The specified service name is managed via the system level init system.
145 Some init systems (e.g. upstart) require that additional arguments be
146 provided in order to directly control service instances whereas other init
147 systems allow for addressing instances of a service directly by name (e.g.
148 systemd).
149
150 The kwargs allow for the additional parameters to be passed to underlying
151 init systems for those systems which require/allow for them. For example,
152 the ceph-osd upstart script requires the id parameter to be passed along
153 in order to identify which running daemon should be reloaded. The follow-
154 ing example restarts the ceph-osd service for instance id=4:
155
156 service_reload('ceph-osd', id=4)
157
158 :param service_name: the name of the service to reload
159 :param restart_on_failure: boolean indicating whether to fallback to a
160 restart if the reload fails.
161 :param **kwargs: additional parameters to pass to the init system when
162 managing services. These will be passed as key=value
163 parameters to the init system's commandline. kwargs
164 are ignored for init systems not allowing additional
165 parameters via the commandline (systemd).
166 """
167 service_result = service('reload', service_name, **kwargs)
168 if not service_result and restart_on_failure:
169 service_result = service('restart', service_name, **kwargs)
170 return service_result
171
172
173def service_pause(service_name, init_dir="/etc/init", initd_dir="/etc/init.d",
174 **kwargs):
175 """Pause a system service.
176
177 Stop it, and prevent it from starting again at boot.
178
179 :param service_name: the name of the service to pause
180 :param init_dir: path to the upstart init directory
181 :param initd_dir: path to the sysv init directory
182 :param **kwargs: additional parameters to pass to the init system when
183 managing services. These will be passed as key=value
184 parameters to the init system's commandline. kwargs
185 are ignored for init systems which do not support
186 key=value arguments via the commandline.
187 """
188 stopped = True
189 if service_running(service_name, **kwargs):
190 stopped = service_stop(service_name, **kwargs)
191 upstart_file = os.path.join(init_dir, "{}.conf".format(service_name))
192 sysv_file = os.path.join(initd_dir, service_name)
193 if init_is_systemd():
194 service('mask', service_name)
195 elif os.path.exists(upstart_file):
196 override_path = os.path.join(
197 init_dir, '{}.override'.format(service_name))
198 with open(override_path, 'w') as fh:
199 fh.write("manual\n")
200 elif os.path.exists(sysv_file):
201 subprocess.check_call(["update-rc.d", service_name, "disable"])
202 else:
203 raise ValueError(
204 "Unable to detect {0} as SystemD, Upstart {1} or"
205 " SysV {2}".format(
206 service_name, upstart_file, sysv_file))
207 return stopped
208
209
210def service_resume(service_name, init_dir="/etc/init",
211 initd_dir="/etc/init.d", **kwargs):
212 """Resume a system service.
213
214 Reenable starting again at boot. Start the service.
215
216 :param service_name: the name of the service to resume
217 :param init_dir: the path to the init dir
218 :param initd dir: the path to the initd dir
219 :param **kwargs: additional parameters to pass to the init system when
220 managing services. These will be passed as key=value
221 parameters to the init system's commandline. kwargs
222 are ignored for systemd enabled systems.
223 """
224 upstart_file = os.path.join(init_dir, "{}.conf".format(service_name))
225 sysv_file = os.path.join(initd_dir, service_name)
226 if init_is_systemd():
227 service('unmask', service_name)
228 elif os.path.exists(upstart_file):
229 override_path = os.path.join(
230 init_dir, '{}.override'.format(service_name))
231 if os.path.exists(override_path):
232 os.unlink(override_path)
233 elif os.path.exists(sysv_file):
234 subprocess.check_call(["update-rc.d", service_name, "enable"])
235 else:
236 raise ValueError(
237 "Unable to detect {0} as SystemD, Upstart {1} or"
238 " SysV {2}".format(
239 service_name, upstart_file, sysv_file))
240 started = service_running(service_name, **kwargs)
241
242 if not started:
243 started = service_start(service_name, **kwargs)
244 return started
245
246
247def service(action, service_name, **kwargs):
248 """Control a system service.
249
250 :param action: the action to take on the service
251 :param service_name: the name of the service to perform th action on
252 :param **kwargs: additional params to be passed to the service command in
253 the form of key=value.
254 """
255 if init_is_systemd():
256 cmd = ['systemctl', action, service_name]
257 else:
258 cmd = ['service', service_name, action]
259 for key, value in six.iteritems(kwargs):
260 parameter = '%s=%s' % (key, value)
261 cmd.append(parameter)
262 return subprocess.call(cmd) == 0
263
264
265_UPSTART_CONF = "/etc/init/{}.conf"
266_INIT_D_CONF = "/etc/init.d/{}"
267
268
269def service_running(service_name, **kwargs):
270 """Determine whether a system service is running.
271
272 :param service_name: the name of the service
273 :param **kwargs: additional args to pass to the service command. This is
274 used to pass additional key=value arguments to the
275 service command line for managing specific instance
276 units (e.g. service ceph-osd status id=2). The kwargs
277 are ignored in systemd services.
278 """
279 if init_is_systemd():
280 return service('is-active', service_name)
281 else:
282 if os.path.exists(_UPSTART_CONF.format(service_name)):
283 try:
284 cmd = ['status', service_name]
285 for key, value in six.iteritems(kwargs):
286 parameter = '%s=%s' % (key, value)
287 cmd.append(parameter)
288 output = subprocess.check_output(cmd,
289 stderr=subprocess.STDOUT).decode('UTF-8')
290 except subprocess.CalledProcessError:
291 return False
292 else:
293 # This works for upstart scripts where the 'service' command
294 # returns a consistent string to represent running
295 # 'start/running'
296 if ("start/running" in output or
297 "is running" in output or
298 "up and running" in output):
299 return True
300 elif os.path.exists(_INIT_D_CONF.format(service_name)):
301 # Check System V scripts init script return codes
302 return service('status', service_name)
303 return False
304
305
306SYSTEMD_SYSTEM = '/run/systemd/system'
307
308
309def init_is_systemd():
310 """Return True if the host system uses systemd, False otherwise."""
311 if lsb_release()['DISTRIB_CODENAME'] == 'trusty':
312 return False
313 return os.path.isdir(SYSTEMD_SYSTEM)
314
315
316def adduser(username, password=None, shell='/bin/bash',
317 system_user=False, primary_group=None,
318 secondary_groups=None, uid=None, home_dir=None):
319 """Add a user to the system.
320
321 Will log but otherwise succeed if the user already exists.
322
323 :param str username: Username to create
324 :param str password: Password for user; if ``None``, create a system user
325 :param str shell: The default shell for the user
326 :param bool system_user: Whether to create a login or system user
327 :param str primary_group: Primary group for user; defaults to username
328 :param list secondary_groups: Optional list of additional groups
329 :param int uid: UID for user being created
330 :param str home_dir: Home directory for user
331
332 :returns: The password database entry struct, as returned by `pwd.getpwnam`
333 """
334 try:
335 user_info = pwd.getpwnam(username)
336 log('user {0} already exists!'.format(username))
337 if uid:
338 user_info = pwd.getpwuid(int(uid))
339 log('user with uid {0} already exists!'.format(uid))
340 except KeyError:
341 log('creating user {0}'.format(username))
342 cmd = ['useradd']
343 if uid:
344 cmd.extend(['--uid', str(uid)])
345 if home_dir:
346 cmd.extend(['--home', str(home_dir)])
347 if system_user or password is None:
348 cmd.append('--system')
349 else:
350 cmd.extend([
351 '--create-home',
352 '--shell', shell,
353 '--password', password,
354 ])
355 if not primary_group:
356 try:
357 grp.getgrnam(username)
358 primary_group = username # avoid "group exists" error
359 except KeyError:
360 pass
361 if primary_group:
362 cmd.extend(['-g', primary_group])
363 if secondary_groups:
364 cmd.extend(['-G', ','.join(secondary_groups)])
365 cmd.append(username)
366 subprocess.check_call(cmd)
367 user_info = pwd.getpwnam(username)
368 return user_info
369
370
371def user_exists(username):
372 """Check if a user exists"""
373 try:
374 pwd.getpwnam(username)
375 user_exists = True
376 except KeyError:
377 user_exists = False
378 return user_exists
379
380
381def uid_exists(uid):
382 """Check if a uid exists"""
383 try:
384 pwd.getpwuid(uid)
385 uid_exists = True
386 except KeyError:
387 uid_exists = False
388 return uid_exists
389
390
391def group_exists(groupname):
392 """Check if a group exists"""
393 try:
394 grp.getgrnam(groupname)
395 group_exists = True
396 except KeyError:
397 group_exists = False
398 return group_exists
399
400
401def gid_exists(gid):
402 """Check if a gid exists"""
403 try:
404 grp.getgrgid(gid)
405 gid_exists = True
406 except KeyError:
407 gid_exists = False
408 return gid_exists
409
410
411def add_group(group_name, system_group=False, gid=None):
412 """Add a group to the system
413
414 Will log but otherwise succeed if the group already exists.
415
416 :param str group_name: group to create
417 :param bool system_group: Create system group
418 :param int gid: GID for user being created
419
420 :returns: The password database entry struct, as returned by `grp.getgrnam`
421 """
422 try:
423 group_info = grp.getgrnam(group_name)
424 log('group {0} already exists!'.format(group_name))
425 if gid:
426 group_info = grp.getgrgid(gid)
427 log('group with gid {0} already exists!'.format(gid))
428 except KeyError:
429 log('creating group {0}'.format(group_name))
430 add_new_group(group_name, system_group, gid)
431 group_info = grp.getgrnam(group_name)
432 return group_info
433
434
435def add_user_to_group(username, group):
436 """Add a user to a group"""
437 cmd = ['gpasswd', '-a', username, group]
438 log("Adding user {} to group {}".format(username, group))
439 subprocess.check_call(cmd)
440
441
442def rsync(from_path, to_path, flags='-r', options=None, timeout=None):
443 """Replicate the contents of a path"""
444 options = options or ['--delete', '--executability']
445 cmd = ['/usr/bin/rsync', flags]
446 if timeout:
447 cmd = ['timeout', str(timeout)] + cmd
448 cmd.extend(options)
449 cmd.append(from_path)
450 cmd.append(to_path)
451 log(" ".join(cmd))
452 return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('UTF-8').strip()
453
454
455def symlink(source, destination):
456 """Create a symbolic link"""
457 log("Symlinking {} as {}".format(source, destination))
458 cmd = [
459 'ln',
460 '-sf',
461 source,
462 destination,
463 ]
464 subprocess.check_call(cmd)
465
466
467def mkdir(path, owner='root', group='root', perms=0o555, force=False):
468 """Create a directory"""
469 log("Making dir {} {}:{} {:o}".format(path, owner, group,
470 perms))
471 uid = pwd.getpwnam(owner).pw_uid
472 gid = grp.getgrnam(group).gr_gid
473 realpath = os.path.abspath(path)
474 path_exists = os.path.exists(realpath)
475 if path_exists and force:
476 if not os.path.isdir(realpath):
477 log("Removing non-directory file {} prior to mkdir()".format(path))
478 os.unlink(realpath)
479 os.makedirs(realpath, perms)
480 elif not path_exists:
481 os.makedirs(realpath, perms)
482 os.chown(realpath, uid, gid)
483 os.chmod(realpath, perms)
484
485
486def write_file(path, content, owner='root', group='root', perms=0o444):
487 """Create or overwrite a file with the contents of a byte string."""
488 log("Writing file {} {}:{} {:o}".format(path, owner, group, perms))
489 uid = pwd.getpwnam(owner).pw_uid
490 gid = grp.getgrnam(group).gr_gid
491 with open(path, 'wb') as target:
492 os.fchown(target.fileno(), uid, gid)
493 os.fchmod(target.fileno(), perms)
494 target.write(content)
495
496
497def fstab_remove(mp):
498 """Remove the given mountpoint entry from /etc/fstab"""
499 return Fstab.remove_by_mountpoint(mp)
500
501
502def fstab_add(dev, mp, fs, options=None):
503 """Adds the given device entry to the /etc/fstab file"""
504 return Fstab.add(dev, mp, fs, options=options)
505
506
507def mount(device, mountpoint, options=None, persist=False, filesystem="ext3"):
508 """Mount a filesystem at a particular mountpoint"""
509 cmd_args = ['mount']
510 if options is not None:
511 cmd_args.extend(['-o', options])
512 cmd_args.extend([device, mountpoint])
513 try:
514 subprocess.check_output(cmd_args)
515 except subprocess.CalledProcessError as e:
516 log('Error mounting {} at {}\n{}'.format(device, mountpoint, e.output))
517 return False
518
519 if persist:
520 return fstab_add(device, mountpoint, filesystem, options=options)
521 return True
522
523
524def umount(mountpoint, persist=False):
525 """Unmount a filesystem"""
526 cmd_args = ['umount', mountpoint]
527 try:
528 subprocess.check_output(cmd_args)
529 except subprocess.CalledProcessError as e:
530 log('Error unmounting {}\n{}'.format(mountpoint, e.output))
531 return False
532
533 if persist:
534 return fstab_remove(mountpoint)
535 return True
536
537
538def mounts():
539 """Get a list of all mounted volumes as [[mountpoint,device],[...]]"""
540 with open('/proc/mounts') as f:
541 # [['/mount/point','/dev/path'],[...]]
542 system_mounts = [m[1::-1] for m in [l.strip().split()
543 for l in f.readlines()]]
544 return system_mounts
545
546
547def fstab_mount(mountpoint):
548 """Mount filesystem using fstab"""
549 cmd_args = ['mount', mountpoint]
550 try:
551 subprocess.check_output(cmd_args)
552 except subprocess.CalledProcessError as e:
553 log('Error unmounting {}\n{}'.format(mountpoint, e.output))
554 return False
555 return True
556
557
558def file_hash(path, hash_type='md5'):
559 """Generate a hash checksum of the contents of 'path' or None if not found.
560
561 :param str hash_type: Any hash alrgorithm supported by :mod:`hashlib`,
562 such as md5, sha1, sha256, sha512, etc.
563 """
564 if os.path.exists(path):
565 h = getattr(hashlib, hash_type)()
566 with open(path, 'rb') as source:
567 h.update(source.read())
568 return h.hexdigest()
569 else:
570 return None
571
572
573def path_hash(path):
574 """Generate a hash checksum of all files matching 'path'. Standard
575 wildcards like '*' and '?' are supported, see documentation for the 'glob'
576 module for more information.
577
578 :return: dict: A { filename: hash } dictionary for all matched files.
579 Empty if none found.
580 """
581 return {
582 filename: file_hash(filename)
583 for filename in glob.iglob(path)
584 }
585
586
587def check_hash(path, checksum, hash_type='md5'):
588 """Validate a file using a cryptographic checksum.
589
590 :param str checksum: Value of the checksum used to validate the file.
591 :param str hash_type: Hash algorithm used to generate `checksum`.
592 Can be any hash alrgorithm supported by :mod:`hashlib`,
593 such as md5, sha1, sha256, sha512, etc.
594 :raises ChecksumError: If the file fails the checksum
595
596 """
597 actual_checksum = file_hash(path, hash_type)
598 if checksum != actual_checksum:
599 raise ChecksumError("'%s' != '%s'" % (checksum, actual_checksum))
600
601
602class ChecksumError(ValueError):
603 """A class derived from Value error to indicate the checksum failed."""
604 pass
605
606
607def restart_on_change(restart_map, stopstart=False, restart_functions=None):
608 """Restart services based on configuration files changing
609
610 This function is used a decorator, for example::
611
612 @restart_on_change({
613 '/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
614 '/etc/apache/sites-enabled/*': [ 'apache2' ]
615 })
616 def config_changed():
617 pass # your code here
618
619 In this example, the cinder-api and cinder-volume services
620 would be restarted if /etc/ceph/ceph.conf is changed by the
621 ceph_client_changed function. The apache2 service would be
622 restarted if any file matching the pattern got changed, created
623 or removed. Standard wildcards are supported, see documentation
624 for the 'glob' module for more information.
625
626 @param restart_map: {path_file_name: [service_name, ...]
627 @param stopstart: DEFAULT false; whether to stop, start OR restart
628 @param restart_functions: nonstandard functions to use to restart services
629 {svc: func, ...}
630 @returns result from decorated function
631 """
632 def wrap(f):
633 @functools.wraps(f)
634 def wrapped_f(*args, **kwargs):
635 return restart_on_change_helper(
636 (lambda: f(*args, **kwargs)), restart_map, stopstart,
637 restart_functions)
638 return wrapped_f
639 return wrap
640
641
642def restart_on_change_helper(lambda_f, restart_map, stopstart=False,
643 restart_functions=None):
644 """Helper function to perform the restart_on_change function.
645
646 This is provided for decorators to restart services if files described
647 in the restart_map have changed after an invocation of lambda_f().
648
649 @param lambda_f: function to call.
650 @param restart_map: {file: [service, ...]}
651 @param stopstart: whether to stop, start or restart a service
652 @param restart_functions: nonstandard functions to use to restart services
653 {svc: func, ...}
654 @returns result of lambda_f()
655 """
656 if restart_functions is None:
657 restart_functions = {}
658 checksums = {path: path_hash(path) for path in restart_map}
659 r = lambda_f()
660 # create a list of lists of the services to restart
661 restarts = [restart_map[path]
662 for path in restart_map
663 if path_hash(path) != checksums[path]]
664 # create a flat list of ordered services without duplicates from lists
665 services_list = list(OrderedDict.fromkeys(itertools.chain(*restarts)))
666 if services_list:
667 actions = ('stop', 'start') if stopstart else ('restart',)
668 for service_name in services_list:
669 if service_name in restart_functions:
670 restart_functions[service_name](service_name)
671 else:
672 for action in actions:
673 service(action, service_name)
674 return r
675
676
677def pwgen(length=None):
678 """Generate a random pasword."""
679 if length is None:
680 # A random length is ok to use a weak PRNG
681 length = random.choice(range(35, 45))
682 alphanumeric_chars = [
683 l for l in (string.ascii_letters + string.digits)
684 if l not in 'l0QD1vAEIOUaeiou']
685 # Use a crypto-friendly PRNG (e.g. /dev/urandom) for making the
686 # actual password
687 random_generator = random.SystemRandom()
688 random_chars = [
689 random_generator.choice(alphanumeric_chars) for _ in range(length)]
690 return(''.join(random_chars))
691
692
693def is_phy_iface(interface):
694 """Returns True if interface is not virtual, otherwise False."""
695 if interface:
696 sys_net = '/sys/class/net'
697 if os.path.isdir(sys_net):
698 for iface in glob.glob(os.path.join(sys_net, '*')):
699 if '/virtual/' in os.path.realpath(iface):
700 continue
701
702 if interface == os.path.basename(iface):
703 return True
704
705 return False
706
707
708def get_bond_master(interface):
709 """Returns bond master if interface is bond slave otherwise None.
710
711 NOTE: the provided interface is expected to be physical
712 """
713 if interface:
714 iface_path = '/sys/class/net/%s' % (interface)
715 if os.path.exists(iface_path):
716 if '/virtual/' in os.path.realpath(iface_path):
717 return None
718
719 master = os.path.join(iface_path, 'master')
720 if os.path.exists(master):
721 master = os.path.realpath(master)
722 # make sure it is a bond master
723 if os.path.exists(os.path.join(master, 'bonding')):
724 return os.path.basename(master)
725
726 return None
727
728
729def list_nics(nic_type=None):
730 """Return a list of nics of given type(s)"""
731 if isinstance(nic_type, six.string_types):
732 int_types = [nic_type]
733 else:
734 int_types = nic_type
735
736 interfaces = []
737 if nic_type:
738 for int_type in int_types:
739 cmd = ['ip', 'addr', 'show', 'label', int_type + '*']
740 ip_output = subprocess.check_output(cmd).decode('UTF-8')
741 ip_output = ip_output.split('\n')
742 ip_output = (line for line in ip_output if line)
743 for line in ip_output:
744 if line.split()[1].startswith(int_type):
745 matched = re.search('.*: (' + int_type +
746 r'[0-9]+\.[0-9]+)@.*', line)
747 if matched:
748 iface = matched.groups()[0]
749 else:
750 iface = line.split()[1].replace(":", "")
751
752 if iface not in interfaces:
753 interfaces.append(iface)
754 else:
755 cmd = ['ip', 'a']
756 ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
757 ip_output = (line.strip() for line in ip_output if line)
758
759 key = re.compile('^[0-9]+:\s+(.+):')
760 for line in ip_output:
761 matched = re.search(key, line)
762 if matched:
763 iface = matched.group(1)
764 iface = iface.partition("@")[0]
765 if iface not in interfaces:
766 interfaces.append(iface)
767
768 return interfaces
769
770
771def set_nic_mtu(nic, mtu):
772 """Set the Maximum Transmission Unit (MTU) on a network interface."""
773 cmd = ['ip', 'link', 'set', nic, 'mtu', mtu]
774 subprocess.check_call(cmd)
775
776
777def get_nic_mtu(nic):
778 """Return the Maximum Transmission Unit (MTU) for a network interface."""
779 cmd = ['ip', 'addr', 'show', nic]
780 ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
781 mtu = ""
782 for line in ip_output:
783 words = line.split()
784 if 'mtu' in words:
785 mtu = words[words.index("mtu") + 1]
786 return mtu
787
788
789def get_nic_hwaddr(nic):
790 """Return the Media Access Control (MAC) for a network interface."""
791 cmd = ['ip', '-o', '-0', 'addr', 'show', nic]
792 ip_output = subprocess.check_output(cmd).decode('UTF-8')
793 hwaddr = ""
794 words = ip_output.split()
795 if 'link/ether' in words:
796 hwaddr = words[words.index('link/ether') + 1]
797 return hwaddr
798
799
800@contextmanager
801def chdir(directory):
802 """Change the current working directory to a different directory for a code
803 block and return the previous directory after the block exits. Useful to
804 run commands from a specificed directory.
805
806 :param str directory: The directory path to change to for this context.
807 """
808 cur = os.getcwd()
809 try:
810 yield os.chdir(directory)
811 finally:
812 os.chdir(cur)
813
814
815def chownr(path, owner, group, follow_links=True, chowntopdir=False):
816 """Recursively change user and group ownership of files and directories
817 in given path. Doesn't chown path itself by default, only its children.
818
819 :param str path: The string path to start changing ownership.
820 :param str owner: The owner string to use when looking up the uid.
821 :param str group: The group string to use when looking up the gid.
822 :param bool follow_links: Also follow and chown links if True
823 :param bool chowntopdir: Also chown path itself if True
824 """
825 uid = pwd.getpwnam(owner).pw_uid
826 gid = grp.getgrnam(group).gr_gid
827 if follow_links:
828 chown = os.chown
829 else:
830 chown = os.lchown
831
832 if chowntopdir:
833 broken_symlink = os.path.lexists(path) and not os.path.exists(path)
834 if not broken_symlink:
835 chown(path, uid, gid)
836 for root, dirs, files in os.walk(path, followlinks=follow_links):
837 for name in dirs + files:
838 full = os.path.join(root, name)
839 broken_symlink = os.path.lexists(full) and not os.path.exists(full)
840 if not broken_symlink:
841 chown(full, uid, gid)
842
843
844def lchownr(path, owner, group):
845 """Recursively change user and group ownership of files and directories
846 in a given path, not following symbolic links. See the documentation for
847 'os.lchown' for more information.
848
849 :param str path: The string path to start changing ownership.
850 :param str owner: The owner string to use when looking up the uid.
851 :param str group: The group string to use when looking up the gid.
852 """
853 chownr(path, owner, group, follow_links=False)
854
855
856def owner(path):
857 """Returns a tuple containing the username & groupname owning the path.
858
859 :param str path: the string path to retrieve the ownership
860 :return tuple(str, str): A (username, groupname) tuple containing the
861 name of the user and group owning the path.
862 :raises OSError: if the specified path does not exist
863 """
864 stat = os.stat(path)
865 username = pwd.getpwuid(stat.st_uid)[0]
866 groupname = grp.getgrgid(stat.st_gid)[0]
867 return username, groupname
868
869
870def get_total_ram():
871 """The total amount of system RAM in bytes.
872
873 This is what is reported by the OS, and may be overcommitted when
874 there are multiple containers hosted on the same machine.
875 """
876 with open('/proc/meminfo', 'r') as f:
877 for line in f.readlines():
878 if line:
879 key, value, unit = line.split()
880 if key == 'MemTotal:':
881 assert unit == 'kB', 'Unknown unit'
882 return int(value) * 1024 # Classic, not KiB.
883 raise NotImplementedError()
884
885
886UPSTART_CONTAINER_TYPE = '/run/container_type'
887
888
889def is_container():
890 """Determine whether unit is running in a container
891
892 @return: boolean indicating if unit is in a container
893 """
894 if init_is_systemd():
895 # Detect using systemd-detect-virt
896 return subprocess.call(['systemd-detect-virt',
897 '--container']) == 0
898 else:
899 # Detect using upstart container file marker
900 return os.path.exists(UPSTART_CONTAINER_TYPE)
901
902
903def add_to_updatedb_prunepath(path, updatedb_path=UPDATEDB_PATH):
904 with open(updatedb_path, 'r+') as f_id:
905 updatedb_text = f_id.read()
906 output = updatedb(updatedb_text, path)
907 f_id.seek(0)
908 f_id.write(output)
909 f_id.truncate()
910
911
912def updatedb(updatedb_text, new_path):
913 lines = [line for line in updatedb_text.split("\n")]
914 for i, line in enumerate(lines):
915 if line.startswith("PRUNEPATHS="):
916 paths_line = line.split("=")[1].replace('"', '')
917 paths = paths_line.split(" ")
918 if new_path not in paths:
919 paths.append(new_path)
920 lines[i] = 'PRUNEPATHS="{}"'.format(' '.join(paths))
921 output = "\n".join(lines)
922 return output
diff --git a/hooks/charmhelpers/core/host_factory/__init__.py b/hooks/charmhelpers/core/host_factory/__init__.py
0new file mode 100644923new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/hooks/charmhelpers/core/host_factory/__init__.py
diff --git a/hooks/charmhelpers/core/host_factory/centos.py b/hooks/charmhelpers/core/host_factory/centos.py
1new file mode 100644924new file mode 100644
index 0000000..7781a39
--- /dev/null
+++ b/hooks/charmhelpers/core/host_factory/centos.py
@@ -0,0 +1,72 @@
1import subprocess
2import yum
3import os
4
5from charmhelpers.core.strutils import BasicStringComparator
6
7
8class CompareHostReleases(BasicStringComparator):
9 """Provide comparisons of Host releases.
10
11 Use in the form of
12
13 if CompareHostReleases(release) > 'trusty':
14 # do something with mitaka
15 """
16
17 def __init__(self, item):
18 raise NotImplementedError(
19 "CompareHostReleases() is not implemented for CentOS")
20
21
22def service_available(service_name):
23 # """Determine whether a system service is available."""
24 if os.path.isdir('/run/systemd/system'):
25 cmd = ['systemctl', 'is-enabled', service_name]
26 else:
27 cmd = ['service', service_name, 'is-enabled']
28 return subprocess.call(cmd) == 0
29
30
31def add_new_group(group_name, system_group=False, gid=None):
32 cmd = ['groupadd']
33 if gid:
34 cmd.extend(['--gid', str(gid)])
35 if system_group:
36 cmd.append('-r')
37 cmd.append(group_name)
38 subprocess.check_call(cmd)
39
40
41def lsb_release():
42 """Return /etc/os-release in a dict."""
43 d = {}
44 with open('/etc/os-release', 'r') as lsb:
45 for l in lsb:
46 s = l.split('=')
47 if len(s) != 2:
48 continue
49 d[s[0].strip()] = s[1].strip()
50 return d
51
52
53def cmp_pkgrevno(package, revno, pkgcache=None):
54 """Compare supplied revno with the revno of the installed package.
55
56 * 1 => Installed revno is greater than supplied arg
57 * 0 => Installed revno is the same as supplied arg
58 * -1 => Installed revno is less than supplied arg
59
60 This function imports YumBase function if the pkgcache argument
61 is None.
62 """
63 if not pkgcache:
64 y = yum.YumBase()
65 packages = y.doPackageLists()
66 pkgcache = {i.Name: i.version for i in packages['installed']}
67 pkg = pkgcache[package]
68 if pkg > revno:
69 return 1
70 if pkg < revno:
71 return -1
72 return 0
diff --git a/hooks/charmhelpers/core/host_factory/ubuntu.py b/hooks/charmhelpers/core/host_factory/ubuntu.py
0new file mode 10064473new file mode 100644
index 0000000..0448288
--- /dev/null
+++ b/hooks/charmhelpers/core/host_factory/ubuntu.py
@@ -0,0 +1,88 @@
1import subprocess
2
3from charmhelpers.core.strutils import BasicStringComparator
4
5
6UBUNTU_RELEASES = (
7 'lucid',
8 'maverick',
9 'natty',
10 'oneiric',
11 'precise',
12 'quantal',
13 'raring',
14 'saucy',
15 'trusty',
16 'utopic',
17 'vivid',
18 'wily',
19 'xenial',
20 'yakkety',
21 'zesty',
22)
23
24
25class CompareHostReleases(BasicStringComparator):
26 """Provide comparisons of Ubuntu releases.
27
28 Use in the form of
29
30 if CompareHostReleases(release) > 'trusty':
31 # do something with mitaka
32 """
33 _list = UBUNTU_RELEASES
34
35
36def service_available(service_name):
37 """Determine whether a system service is available"""
38 try:
39 subprocess.check_output(
40 ['service', service_name, 'status'],
41 stderr=subprocess.STDOUT).decode('UTF-8')
42 except subprocess.CalledProcessError as e:
43 return b'unrecognized service' not in e.output
44 else:
45 return True
46
47
48def add_new_group(group_name, system_group=False, gid=None):
49 cmd = ['addgroup']
50 if gid:
51 cmd.extend(['--gid', str(gid)])
52 if system_group:
53 cmd.append('--system')
54 else:
55 cmd.extend([
56 '--group',
57 ])
58 cmd.append(group_name)
59 subprocess.check_call(cmd)
60
61
62def lsb_release():
63 """Return /etc/lsb-release in a dict"""
64 d = {}
65 with open('/etc/lsb-release', 'r') as lsb:
66 for l in lsb:
67 k, v = l.split('=')
68 d[k.strip()] = v.strip()
69 return d
70
71
72def cmp_pkgrevno(package, revno, pkgcache=None):
73 """Compare supplied revno with the revno of the installed package.
74
75 * 1 => Installed revno is greater than supplied arg
76 * 0 => Installed revno is the same as supplied arg
77 * -1 => Installed revno is less than supplied arg
78
79 This function imports apt_cache function from charmhelpers.fetch if
80 the pkgcache argument is None. Be sure to add charmhelpers.fetch if
81 you call this function, or pass an apt_pkg.Cache() instance.
82 """
83 import apt_pkg
84 if not pkgcache:
85 from charmhelpers.fetch import apt_cache
86 pkgcache = apt_cache()
87 pkg = pkgcache[package]
88 return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
diff --git a/hooks/charmhelpers/core/hugepage.py b/hooks/charmhelpers/core/hugepage.py
0new file mode 10064489new file mode 100644
index 0000000..54b5b5e
--- /dev/null
+++ b/hooks/charmhelpers/core/hugepage.py
@@ -0,0 +1,69 @@
1# -*- coding: utf-8 -*-
2
3# Copyright 2014-2015 Canonical Limited.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17import yaml
18from charmhelpers.core import fstab
19from charmhelpers.core import sysctl
20from charmhelpers.core.host import (
21 add_group,
22 add_user_to_group,
23 fstab_mount,
24 mkdir,
25)
26from charmhelpers.core.strutils import bytes_from_string
27from subprocess import check_output
28
29
30def hugepage_support(user, group='hugetlb', nr_hugepages=256,
31 max_map_count=65536, mnt_point='/run/hugepages/kvm',
32 pagesize='2MB', mount=True, set_shmmax=False):
33 """Enable hugepages on system.
34
35 Args:
36 user (str) -- Username to allow access to hugepages to
37 group (str) -- Group name to own hugepages
38 nr_hugepages (int) -- Number of pages to reserve
39 max_map_count (int) -- Number of Virtual Memory Areas a process can own
40 mnt_point (str) -- Directory to mount hugepages on
41 pagesize (str) -- Size of hugepages
42 mount (bool) -- Whether to Mount hugepages
43 """
44 group_info = add_group(group)
45 gid = group_info.gr_gid
46 add_user_to_group(user, group)
47 if max_map_count < 2 * nr_hugepages:
48 max_map_count = 2 * nr_hugepages
49 sysctl_settings = {
50 'vm.nr_hugepages': nr_hugepages,
51 'vm.max_map_count': max_map_count,
52 'vm.hugetlb_shm_group': gid,
53 }
54 if set_shmmax:
55 shmmax_current = int(check_output(['sysctl', '-n', 'kernel.shmmax']))
56 shmmax_minsize = bytes_from_string(pagesize) * nr_hugepages
57 if shmmax_minsize > shmmax_current:
58 sysctl_settings['kernel.shmmax'] = shmmax_minsize
59 sysctl.create(yaml.dump(sysctl_settings), '/etc/sysctl.d/10-hugepage.conf')
60 mkdir(mnt_point, owner='root', group='root', perms=0o755, force=False)
61 lfstab = fstab.Fstab()
62 fstab_entry = lfstab.get_entry_by_attr('mountpoint', mnt_point)
63 if fstab_entry:
64 lfstab.remove_entry(fstab_entry)
65 entry = lfstab.Entry('nodev', mnt_point, 'hugetlbfs',
66 'mode=1770,gid={},pagesize={}'.format(gid, pagesize), 0, 0)
67 lfstab.add_entry(entry)
68 if mount:
69 fstab_mount(mnt_point)
diff --git a/hooks/charmhelpers/core/kernel.py b/hooks/charmhelpers/core/kernel.py
0new file mode 10064470new file mode 100644
index 0000000..2d40452
--- /dev/null
+++ b/hooks/charmhelpers/core/kernel.py
@@ -0,0 +1,72 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import re
19import subprocess
20
21from charmhelpers.osplatform import get_platform
22from charmhelpers.core.hookenv import (
23 log,
24 INFO
25)
26
27__platform__ = get_platform()
28if __platform__ == "ubuntu":
29 from charmhelpers.core.kernel_factory.ubuntu import (
30 persistent_modprobe,
31 update_initramfs,
32 ) # flake8: noqa -- ignore F401 for this import
33elif __platform__ == "centos":
34 from charmhelpers.core.kernel_factory.centos import (
35 persistent_modprobe,
36 update_initramfs,
37 ) # flake8: noqa -- ignore F401 for this import
38
39__author__ = "Jorge Niedbalski <jorge.niedbalski@canonical.com>"
40
41
42def modprobe(module, persist=True):
43 """Load a kernel module and configure for auto-load on reboot."""
44 cmd = ['modprobe', module]
45
46 log('Loading kernel module %s' % module, level=INFO)
47
48 subprocess.check_call(cmd)
49 if persist:
50 persistent_modprobe(module)
51
52
53def rmmod(module, force=False):
54 """Remove a module from the linux kernel"""
55 cmd = ['rmmod']
56 if force:
57 cmd.append('-f')
58 cmd.append(module)
59 log('Removing kernel module %s' % module, level=INFO)
60 return subprocess.check_call(cmd)
61
62
63def lsmod():
64 """Shows what kernel modules are currently loaded"""
65 return subprocess.check_output(['lsmod'],
66 universal_newlines=True)
67
68
69def is_module_loaded(module):
70 """Checks if a kernel module is already loaded"""
71 matches = re.findall('^%s[ ]+' % module, lsmod(), re.M)
72 return len(matches) > 0
diff --git a/hooks/charmhelpers/core/kernel_factory/__init__.py b/hooks/charmhelpers/core/kernel_factory/__init__.py
0new file mode 10064473new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/hooks/charmhelpers/core/kernel_factory/__init__.py
diff --git a/hooks/charmhelpers/core/kernel_factory/centos.py b/hooks/charmhelpers/core/kernel_factory/centos.py
1new file mode 10064474new file mode 100644
index 0000000..1c402c1
--- /dev/null
+++ b/hooks/charmhelpers/core/kernel_factory/centos.py
@@ -0,0 +1,17 @@
1import subprocess
2import os
3
4
5def persistent_modprobe(module):
6 """Load a kernel module and configure for auto-load on reboot."""
7 if not os.path.exists('/etc/rc.modules'):
8 open('/etc/rc.modules', 'a')
9 os.chmod('/etc/rc.modules', 111)
10 with open('/etc/rc.modules', 'r+') as modules:
11 if module not in modules.read():
12 modules.write('modprobe %s\n' % module)
13
14
15def update_initramfs(version='all'):
16 """Updates an initramfs image."""
17 return subprocess.check_call(["dracut", "-f", version])
diff --git a/hooks/charmhelpers/core/kernel_factory/ubuntu.py b/hooks/charmhelpers/core/kernel_factory/ubuntu.py
0new file mode 10064418new file mode 100644
index 0000000..3de372f
--- /dev/null
+++ b/hooks/charmhelpers/core/kernel_factory/ubuntu.py
@@ -0,0 +1,13 @@
1import subprocess
2
3
4def persistent_modprobe(module):
5 """Load a kernel module and configure for auto-load on reboot."""
6 with open('/etc/modules', 'r+') as modules:
7 if module not in modules.read():
8 modules.write(module + "\n")
9
10
11def update_initramfs(version='all'):
12 """Updates an initramfs image."""
13 return subprocess.check_call(["update-initramfs", "-k", version, "-u"])
diff --git a/hooks/charmhelpers/core/services/__init__.py b/hooks/charmhelpers/core/services/__init__.py
0new file mode 10064414new file mode 100644
index 0000000..61fd074
--- /dev/null
+++ b/hooks/charmhelpers/core/services/__init__.py
@@ -0,0 +1,16 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from .base import * # NOQA
16from .helpers import * # NOQA
diff --git a/hooks/charmhelpers/core/services/base.py b/hooks/charmhelpers/core/services/base.py
0new file mode 10064417new file mode 100644
index 0000000..ca9dc99
--- /dev/null
+++ b/hooks/charmhelpers/core/services/base.py
@@ -0,0 +1,351 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import json
17from inspect import getargspec
18from collections import Iterable, OrderedDict
19
20from charmhelpers.core import host
21from charmhelpers.core import hookenv
22
23
24__all__ = ['ServiceManager', 'ManagerCallback',
25 'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
26 'service_restart', 'service_stop']
27
28
29class ServiceManager(object):
30 def __init__(self, services=None):
31 """
32 Register a list of services, given their definitions.
33
34 Service definitions are dicts in the following formats (all keys except
35 'service' are optional)::
36
37 {
38 "service": <service name>,
39 "required_data": <list of required data contexts>,
40 "provided_data": <list of provided data contexts>,
41 "data_ready": <one or more callbacks>,
42 "data_lost": <one or more callbacks>,
43 "start": <one or more callbacks>,
44 "stop": <one or more callbacks>,
45 "ports": <list of ports to manage>,
46 }
47
48 The 'required_data' list should contain dicts of required data (or
49 dependency managers that act like dicts and know how to collect the data).
50 Only when all items in the 'required_data' list are populated are the list
51 of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
52 information.
53
54 The 'provided_data' list should contain relation data providers, most likely
55 a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
56 that will indicate a set of data to set on a given relation.
57
58 The 'data_ready' value should be either a single callback, or a list of
59 callbacks, to be called when all items in 'required_data' pass `is_ready()`.
60 Each callback will be called with the service name as the only parameter.
61 After all of the 'data_ready' callbacks are called, the 'start' callbacks
62 are fired.
63
64 The 'data_lost' value should be either a single callback, or a list of
65 callbacks, to be called when a 'required_data' item no longer passes
66 `is_ready()`. Each callback will be called with the service name as the
67 only parameter. After all of the 'data_lost' callbacks are called,
68 the 'stop' callbacks are fired.
69
70 The 'start' value should be either a single callback, or a list of
71 callbacks, to be called when starting the service, after the 'data_ready'
72 callbacks are complete. Each callback will be called with the service
73 name as the only parameter. This defaults to
74 `[host.service_start, services.open_ports]`.
75
76 The 'stop' value should be either a single callback, or a list of
77 callbacks, to be called when stopping the service. If the service is
78 being stopped because it no longer has all of its 'required_data', this
79 will be called after all of the 'data_lost' callbacks are complete.
80 Each callback will be called with the service name as the only parameter.
81 This defaults to `[services.close_ports, host.service_stop]`.
82
83 The 'ports' value should be a list of ports to manage. The default
84 'start' handler will open the ports after the service is started,
85 and the default 'stop' handler will close the ports prior to stopping
86 the service.
87
88
89 Examples:
90
91 The following registers an Upstart service called bingod that depends on
92 a mongodb relation and which runs a custom `db_migrate` function prior to
93 restarting the service, and a Runit service called spadesd::
94
95 manager = services.ServiceManager([
96 {
97 'service': 'bingod',
98 'ports': [80, 443],
99 'required_data': [MongoRelation(), config(), {'my': 'data'}],
100 'data_ready': [
101 services.template(source='bingod.conf'),
102 services.template(source='bingod.ini',
103 target='/etc/bingod.ini',
104 owner='bingo', perms=0400),
105 ],
106 },
107 {
108 'service': 'spadesd',
109 'data_ready': services.template(source='spadesd_run.j2',
110 target='/etc/sv/spadesd/run',
111 perms=0555),
112 'start': runit_start,
113 'stop': runit_stop,
114 },
115 ])
116 manager.manage()
117 """
118 self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
119 self._ready = None
120 self.services = OrderedDict()
121 for service in services or []:
122 service_name = service['service']
123 self.services[service_name] = service
124
125 def manage(self):
126 """
127 Handle the current hook by doing The Right Thing with the registered services.
128 """
129 hookenv._run_atstart()
130 try:
131 hook_name = hookenv.hook_name()
132 if hook_name == 'stop':
133 self.stop_services()
134 else:
135 self.reconfigure_services()
136 self.provide_data()
137 except SystemExit as x:
138 if x.code is None or x.code == 0:
139 hookenv._run_atexit()
140 hookenv._run_atexit()
141
142 def provide_data(self):
143 """
144 Set the relation data for each provider in the ``provided_data`` list.
145
146 A provider must have a `name` attribute, which indicates which relation
147 to set data on, and a `provide_data()` method, which returns a dict of
148 data to set.
149
150 The `provide_data()` method can optionally accept two parameters:
151
152 * ``remote_service`` The name of the remote service that the data will
153 be provided to. The `provide_data()` method will be called once
154 for each connected service (not unit). This allows the method to
155 tailor its data to the given service.
156 * ``service_ready`` Whether or not the service definition had all of
157 its requirements met, and thus the ``data_ready`` callbacks run.
158
159 Note that the ``provided_data`` methods are now called **after** the
160 ``data_ready`` callbacks are run. This gives the ``data_ready`` callbacks
161 a chance to generate any data necessary for the providing to the remote
162 services.
163 """
164 for service_name, service in self.services.items():
165 service_ready = self.is_ready(service_name)
166 for provider in service.get('provided_data', []):
167 for relid in hookenv.relation_ids(provider.name):
168 units = hookenv.related_units(relid)
169 if not units:
170 continue
171 remote_service = units[0].split('/')[0]
172 argspec = getargspec(provider.provide_data)
173 if len(argspec.args) > 1:
174 data = provider.provide_data(remote_service, service_ready)
175 else:
176 data = provider.provide_data()
177 if data:
178 hookenv.relation_set(relid, data)
179
180 def reconfigure_services(self, *service_names):
181 """
182 Update all files for one or more registered services, and,
183 if ready, optionally restart them.
184
185 If no service names are given, reconfigures all registered services.
186 """
187 for service_name in service_names or self.services.keys():
188 if self.is_ready(service_name):
189 self.fire_event('data_ready', service_name)
190 self.fire_event('start', service_name, default=[
191 service_restart,
192 manage_ports])
193 self.save_ready(service_name)
194 else:
195 if self.was_ready(service_name):
196 self.fire_event('data_lost', service_name)
197 self.fire_event('stop', service_name, default=[
198 manage_ports,
199 service_stop])
200 self.save_lost(service_name)
201
202 def stop_services(self, *service_names):
203 """
204 Stop one or more registered services, by name.
205
206 If no service names are given, stops all registered services.
207 """
208 for service_name in service_names or self.services.keys():
209 self.fire_event('stop', service_name, default=[
210 manage_ports,
211 service_stop])
212
213 def get_service(self, service_name):
214 """
215 Given the name of a registered service, return its service definition.
216 """
217 service = self.services.get(service_name)
218 if not service:
219 raise KeyError('Service not registered: %s' % service_name)
220 return service
221
222 def fire_event(self, event_name, service_name, default=None):
223 """
224 Fire a data_ready, data_lost, start, or stop event on a given service.
225 """
226 service = self.get_service(service_name)
227 callbacks = service.get(event_name, default)
228 if not callbacks:
229 return
230 if not isinstance(callbacks, Iterable):
231 callbacks = [callbacks]
232 for callback in callbacks:
233 if isinstance(callback, ManagerCallback):
234 callback(self, service_name, event_name)
235 else:
236 callback(service_name)
237
238 def is_ready(self, service_name):
239 """
240 Determine if a registered service is ready, by checking its 'required_data'.
241
242 A 'required_data' item can be any mapping type, and is considered ready
243 if `bool(item)` evaluates as True.
244 """
245 service = self.get_service(service_name)
246 reqs = service.get('required_data', [])
247 return all(bool(req) for req in reqs)
248
249 def _load_ready_file(self):
250 if self._ready is not None:
251 return
252 if os.path.exists(self._ready_file):
253 with open(self._ready_file) as fp:
254 self._ready = set(json.load(fp))
255 else:
256 self._ready = set()
257
258 def _save_ready_file(self):
259 if self._ready is None:
260 return
261 with open(self._ready_file, 'w') as fp:
262 json.dump(list(self._ready), fp)
263
264 def save_ready(self, service_name):
265 """
266 Save an indicator that the given service is now data_ready.
267 """
268 self._load_ready_file()
269 self._ready.add(service_name)
270 self._save_ready_file()
271
272 def save_lost(self, service_name):
273 """
274 Save an indicator that the given service is no longer data_ready.
275 """
276 self._load_ready_file()
277 self._ready.discard(service_name)
278 self._save_ready_file()
279
280 def was_ready(self, service_name):
281 """
282 Determine if the given service was previously data_ready.
283 """
284 self._load_ready_file()
285 return service_name in self._ready
286
287
288class ManagerCallback(object):
289 """
290 Special case of a callback that takes the `ServiceManager` instance
291 in addition to the service name.
292
293 Subclasses should implement `__call__` which should accept three parameters:
294
295 * `manager` The `ServiceManager` instance
296 * `service_name` The name of the service it's being triggered for
297 * `event_name` The name of the event that this callback is handling
298 """
299 def __call__(self, manager, service_name, event_name):
300 raise NotImplementedError()
301
302
303class PortManagerCallback(ManagerCallback):
304 """
305 Callback class that will open or close ports, for use as either
306 a start or stop action.
307 """
308 def __call__(self, manager, service_name, event_name):
309 service = manager.get_service(service_name)
310 new_ports = service.get('ports', [])
311 port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
312 if os.path.exists(port_file):
313 with open(port_file) as fp:
314 old_ports = fp.read().split(',')
315 for old_port in old_ports:
316 if bool(old_port):
317 old_port = int(old_port)
318 if old_port not in new_ports:
319 hookenv.close_port(old_port)
320 with open(port_file, 'w') as fp:
321 fp.write(','.join(str(port) for port in new_ports))
322 for port in new_ports:
323 if event_name == 'start':
324 hookenv.open_port(port)
325 elif event_name == 'stop':
326 hookenv.close_port(port)
327
328
329def service_stop(service_name):
330 """
331 Wrapper around host.service_stop to prevent spurious "unknown service"
332 messages in the logs.
333 """
334 if host.service_running(service_name):
335 host.service_stop(service_name)
336
337
338def service_restart(service_name):
339 """
340 Wrapper around host.service_restart to prevent spurious "unknown service"
341 messages in the logs.
342 """
343 if host.service_available(service_name):
344 if host.service_running(service_name):
345 host.service_restart(service_name)
346 else:
347 host.service_start(service_name)
348
349
350# Convenience aliases
351open_ports = close_ports = manage_ports = PortManagerCallback()
diff --git a/hooks/charmhelpers/core/services/helpers.py b/hooks/charmhelpers/core/services/helpers.py
0new file mode 100644352new file mode 100644
index 0000000..3e6e30d
--- /dev/null
+++ b/hooks/charmhelpers/core/services/helpers.py
@@ -0,0 +1,290 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import yaml
17
18from charmhelpers.core import hookenv
19from charmhelpers.core import host
20from charmhelpers.core import templating
21
22from charmhelpers.core.services.base import ManagerCallback
23
24
25__all__ = ['RelationContext', 'TemplateCallback',
26 'render_template', 'template']
27
28
29class RelationContext(dict):
30 """
31 Base class for a context generator that gets relation data from juju.
32
33 Subclasses must provide the attributes `name`, which is the name of the
34 interface of interest, `interface`, which is the type of the interface of
35 interest, and `required_keys`, which is the set of keys required for the
36 relation to be considered complete. The data for all interfaces matching
37 the `name` attribute that are complete will used to populate the dictionary
38 values (see `get_data`, below).
39
40 The generated context will be namespaced under the relation :attr:`name`,
41 to prevent potential naming conflicts.
42
43 :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
44 :param list additional_required_keys: Extend the list of :attr:`required_keys`
45 """
46 name = None
47 interface = None
48
49 def __init__(self, name=None, additional_required_keys=None):
50 if not hasattr(self, 'required_keys'):
51 self.required_keys = []
52
53 if name is not None:
54 self.name = name
55 if additional_required_keys:
56 self.required_keys.extend(additional_required_keys)
57 self.get_data()
58
59 def __bool__(self):
60 """
61 Returns True if all of the required_keys are available.
62 """
63 return self.is_ready()
64
65 __nonzero__ = __bool__
66
67 def __repr__(self):
68 return super(RelationContext, self).__repr__()
69
70 def is_ready(self):
71 """
72 Returns True if all of the `required_keys` are available from any units.
73 """
74 ready = len(self.get(self.name, [])) > 0
75 if not ready:
76 hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
77 return ready
78
79 def _is_ready(self, unit_data):
80 """
81 Helper method that tests a set of relation data and returns True if
82 all of the `required_keys` are present.
83 """
84 return set(unit_data.keys()).issuperset(set(self.required_keys))
85
86 def get_data(self):
87 """
88 Retrieve the relation data for each unit involved in a relation and,
89 if complete, store it in a list under `self[self.name]`. This
90 is automatically called when the RelationContext is instantiated.
91
92 The units are sorted lexographically first by the service ID, then by
93 the unit ID. Thus, if an interface has two other services, 'db:1'
94 and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
95 and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
96 set of data, the relation data for the units will be stored in the
97 order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
98
99 If you only care about a single unit on the relation, you can just
100 access it as `{{ interface[0]['key'] }}`. However, if you can at all
101 support multiple units on a relation, you should iterate over the list,
102 like::
103
104 {% for unit in interface -%}
105 {{ unit['key'] }}{% if not loop.last %},{% endif %}
106 {%- endfor %}
107
108 Note that since all sets of relation data from all related services and
109 units are in a single list, if you need to know which service or unit a
110 set of data came from, you'll need to extend this class to preserve
111 that information.
112 """
113 if not hookenv.relation_ids(self.name):
114 return
115
116 ns = self.setdefault(self.name, [])
117 for rid in sorted(hookenv.relation_ids(self.name)):
118 for unit in sorted(hookenv.related_units(rid)):
119 reldata = hookenv.relation_get(rid=rid, unit=unit)
120 if self._is_ready(reldata):
121 ns.append(reldata)
122
123 def provide_data(self):
124 """
125 Return data to be relation_set for this interface.
126 """
127 return {}
128
129
130class MysqlRelation(RelationContext):
131 """
132 Relation context for the `mysql` interface.
133
134 :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
135 :param list additional_required_keys: Extend the list of :attr:`required_keys`
136 """
137 name = 'db'
138 interface = 'mysql'
139
140 def __init__(self, *args, **kwargs):
141 self.required_keys = ['host', 'user', 'password', 'database']
142 RelationContext.__init__(self, *args, **kwargs)
143
144
145class HttpRelation(RelationContext):
146 """
147 Relation context for the `http` interface.
148
149 :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
150 :param list additional_required_keys: Extend the list of :attr:`required_keys`
151 """
152 name = 'website'
153 interface = 'http'
154
155 def __init__(self, *args, **kwargs):
156 self.required_keys = ['host', 'port']
157 RelationContext.__init__(self, *args, **kwargs)
158
159 def provide_data(self):
160 return {
161 'host': hookenv.unit_get('private-address'),
162 'port': 80,
163 }
164
165
166class RequiredConfig(dict):
167 """
168 Data context that loads config options with one or more mandatory options.
169
170 Once the required options have been changed from their default values, all
171 config options will be available, namespaced under `config` to prevent
172 potential naming conflicts (for example, between a config option and a
173 relation property).
174
175 :param list *args: List of options that must be changed from their default values.
176 """
177
178 def __init__(self, *args):
179 self.required_options = args
180 self['config'] = hookenv.config()
181 with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
182 self.config = yaml.load(fp).get('options', {})
183
184 def __bool__(self):
185 for option in self.required_options:
186 if option not in self['config']:
187 return False
188 current_value = self['config'][option]
189 default_value = self.config[option].get('default')
190 if current_value == default_value:
191 return False
192 if current_value in (None, '') and default_value in (None, ''):
193 return False
194 return True
195
196 def __nonzero__(self):
197 return self.__bool__()
198
199
200class StoredContext(dict):
201 """
202 A data context that always returns the data that it was first created with.
203
204 This is useful to do a one-time generation of things like passwords, that
205 will thereafter use the same value that was originally generated, instead
206 of generating a new value each time it is run.
207 """
208 def __init__(self, file_name, config_data):
209 """
210 If the file exists, populate `self` with the data from the file.
211 Otherwise, populate with the given data and persist it to the file.
212 """
213 if os.path.exists(file_name):
214 self.update(self.read_context(file_name))
215 else:
216 self.store_context(file_name, config_data)
217 self.update(config_data)
218
219 def store_context(self, file_name, config_data):
220 if not os.path.isabs(file_name):
221 file_name = os.path.join(hookenv.charm_dir(), file_name)
222 with open(file_name, 'w') as file_stream:
223 os.fchmod(file_stream.fileno(), 0o600)
224 yaml.dump(config_data, file_stream)
225
226 def read_context(self, file_name):
227 if not os.path.isabs(file_name):
228 file_name = os.path.join(hookenv.charm_dir(), file_name)
229 with open(file_name, 'r') as file_stream:
230 data = yaml.load(file_stream)
231 if not data:
232 raise OSError("%s is empty" % file_name)
233 return data
234
235
236class TemplateCallback(ManagerCallback):
237 """
238 Callback class that will render a Jinja2 template, for use as a ready
239 action.
240
241 :param str source: The template source file, relative to
242 `$CHARM_DIR/templates`
243
244 :param str target: The target to write the rendered template to (or None)
245 :param str owner: The owner of the rendered file
246 :param str group: The group of the rendered file
247 :param int perms: The permissions of the rendered file
248 :param partial on_change_action: functools partial to be executed when
249 rendered file changes
250 :param jinja2 loader template_loader: A jinja2 template loader
251
252 :return str: The rendered template
253 """
254 def __init__(self, source, target,
255 owner='root', group='root', perms=0o444,
256 on_change_action=None, template_loader=None):
257 self.source = source
258 self.target = target
259 self.owner = owner
260 self.group = group
261 self.perms = perms
262 self.on_change_action = on_change_action
263 self.template_loader = template_loader
264
265 def __call__(self, manager, service_name, event_name):
266 pre_checksum = ''
267 if self.on_change_action and os.path.isfile(self.target):
268 pre_checksum = host.file_hash(self.target)
269 service = manager.get_service(service_name)
270 context = {'ctx': {}}
271 for ctx in service.get('required_data', []):
272 context.update(ctx)
273 context['ctx'].update(ctx)
274
275 result = templating.render(self.source, self.target, context,
276 self.owner, self.group, self.perms,
277 template_loader=self.template_loader)
278 if self.on_change_action:
279 if pre_checksum == host.file_hash(self.target):
280 hookenv.log(
281 'No change detected: {}'.format(self.target),
282 hookenv.DEBUG)
283 else:
284 self.on_change_action()
285
286 return result
287
288
289# Convenience aliases for templates
290render_template = template = TemplateCallback
diff --git a/hooks/charmhelpers/core/strutils.py b/hooks/charmhelpers/core/strutils.py
0new file mode 100644291new file mode 100644
index 0000000..685dabd
--- /dev/null
+++ b/hooks/charmhelpers/core/strutils.py
@@ -0,0 +1,123 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import six
19import re
20
21
22def bool_from_string(value):
23 """Interpret string value as boolean.
24
25 Returns True if value translates to True otherwise False.
26 """
27 if isinstance(value, six.string_types):
28 value = six.text_type(value)
29 else:
30 msg = "Unable to interpret non-string value '%s' as boolean" % (value)
31 raise ValueError(msg)
32
33 value = value.strip().lower()
34
35 if value in ['y', 'yes', 'true', 't', 'on']:
36 return True
37 elif value in ['n', 'no', 'false', 'f', 'off']:
38 return False
39
40 msg = "Unable to interpret string value '%s' as boolean" % (value)
41 raise ValueError(msg)
42
43
44def bytes_from_string(value):
45 """Interpret human readable string value as bytes.
46
47 Returns int
48 """
49 BYTE_POWER = {
50 'K': 1,
51 'KB': 1,
52 'M': 2,
53 'MB': 2,
54 'G': 3,
55 'GB': 3,
56 'T': 4,
57 'TB': 4,
58 'P': 5,
59 'PB': 5,
60 }
61 if isinstance(value, six.string_types):
62 value = six.text_type(value)
63 else:
64 msg = "Unable to interpret non-string value '%s' as boolean" % (value)
65 raise ValueError(msg)
66 matches = re.match("([0-9]+)([a-zA-Z]+)", value)
67 if not matches:
68 msg = "Unable to interpret string value '%s' as bytes" % (value)
69 raise ValueError(msg)
70 return int(matches.group(1)) * (1024 ** BYTE_POWER[matches.group(2)])
71
72
73class BasicStringComparator(object):
74 """Provides a class that will compare strings from an iterator type object.
75 Used to provide > and < comparisons on strings that may not necessarily be
76 alphanumerically ordered. e.g. OpenStack or Ubuntu releases AFTER the
77 z-wrap.
78 """
79
80 _list = None
81
82 def __init__(self, item):
83 if self._list is None:
84 raise Exception("Must define the _list in the class definition!")
85 try:
86 self.index = self._list.index(item)
87 except Exception:
88 raise KeyError("Item '{}' is not in list '{}'"
89 .format(item, self._list))
90
91 def __eq__(self, other):
92 assert isinstance(other, str) or isinstance(other, self.__class__)
93 return self.index == self._list.index(other)
94
95 def __ne__(self, other):
96 return not self.__eq__(other)
97
98 def __lt__(self, other):
99 assert isinstance(other, str) or isinstance(other, self.__class__)
100 return self.index < self._list.index(other)
101
102 def __ge__(self, other):
103 return not self.__lt__(other)
104
105 def __gt__(self, other):
106 assert isinstance(other, str) or isinstance(other, self.__class__)
107 return self.index > self._list.index(other)
108
109 def __le__(self, other):
110 return not self.__gt__(other)
111
112 def __str__(self):
113 """Always give back the item at the index so it can be used in
114 comparisons like:
115
116 s_mitaka = CompareOpenStack('mitaka')
117 s_newton = CompareOpenstack('newton')
118
119 assert s_newton > s_mitaka
120
121 @returns: <string>
122 """
123 return self._list[self.index]
diff --git a/hooks/charmhelpers/core/sysctl.py b/hooks/charmhelpers/core/sysctl.py
0new file mode 100644124new file mode 100644
index 0000000..6e413e3
--- /dev/null
+++ b/hooks/charmhelpers/core/sysctl.py
@@ -0,0 +1,54 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import yaml
19
20from subprocess import check_call
21
22from charmhelpers.core.hookenv import (
23 log,
24 DEBUG,
25 ERROR,
26)
27
28__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
29
30
31def create(sysctl_dict, sysctl_file):
32 """Creates a sysctl.conf file from a YAML associative array
33
34 :param sysctl_dict: a YAML-formatted string of sysctl options eg "{ 'kernel.max_pid': 1337 }"
35 :type sysctl_dict: str
36 :param sysctl_file: path to the sysctl file to be saved
37 :type sysctl_file: str or unicode
38 :returns: None
39 """
40 try:
41 sysctl_dict_parsed = yaml.safe_load(sysctl_dict)
42 except yaml.YAMLError:
43 log("Error parsing YAML sysctl_dict: {}".format(sysctl_dict),
44 level=ERROR)
45 return
46
47 with open(sysctl_file, "w") as fd:
48 for key, value in sysctl_dict_parsed.items():
49 fd.write("{}={}\n".format(key, value))
50
51 log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed),
52 level=DEBUG)
53
54 check_call(["sysctl", "-p", sysctl_file])
diff --git a/hooks/charmhelpers/core/templating.py b/hooks/charmhelpers/core/templating.py
0new file mode 10064455new file mode 100644
index 0000000..7b801a3
--- /dev/null
+++ b/hooks/charmhelpers/core/templating.py
@@ -0,0 +1,84 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import sys
17
18from charmhelpers.core import host
19from charmhelpers.core import hookenv
20
21
22def render(source, target, context, owner='root', group='root',
23 perms=0o444, templates_dir=None, encoding='UTF-8', template_loader=None):
24 """
25 Render a template.
26
27 The `source` path, if not absolute, is relative to the `templates_dir`.
28
29 The `target` path should be absolute. It can also be `None`, in which
30 case no file will be written.
31
32 The context should be a dict containing the values to be replaced in the
33 template.
34
35 The `owner`, `group`, and `perms` options will be passed to `write_file`.
36
37 If omitted, `templates_dir` defaults to the `templates` folder in the charm.
38
39 The rendered template will be written to the file as well as being returned
40 as a string.
41
42 Note: Using this requires python-jinja2 or python3-jinja2; if it is not
43 installed, calling this will attempt to use charmhelpers.fetch.apt_install
44 to install it.
45 """
46 try:
47 from jinja2 import FileSystemLoader, Environment, exceptions
48 except ImportError:
49 try:
50 from charmhelpers.fetch import apt_install
51 except ImportError:
52 hookenv.log('Could not import jinja2, and could not import '
53 'charmhelpers.fetch to install it',
54 level=hookenv.ERROR)
55 raise
56 if sys.version_info.major == 2:
57 apt_install('python-jinja2', fatal=True)
58 else:
59 apt_install('python3-jinja2', fatal=True)
60 from jinja2 import FileSystemLoader, Environment, exceptions
61
62 if template_loader:
63 template_env = Environment(loader=template_loader)
64 else:
65 if templates_dir is None:
66 templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
67 template_env = Environment(loader=FileSystemLoader(templates_dir))
68 try:
69 source = source
70 template = template_env.get_template(source)
71 except exceptions.TemplateNotFound as e:
72 hookenv.log('Could not load template %s from %s.' %
73 (source, templates_dir),
74 level=hookenv.ERROR)
75 raise e
76 content = template.render(context)
77 if target is not None:
78 target_dir = os.path.dirname(target)
79 if not os.path.exists(target_dir):
80 # This is a terrible default directory permission, as the file
81 # or its siblings will often contain secrets.
82 host.mkdir(os.path.dirname(target), owner, group, perms=0o755)
83 host.write_file(target, content.encode(encoding), owner, group, perms)
84 return content
diff --git a/hooks/charmhelpers/core/unitdata.py b/hooks/charmhelpers/core/unitdata.py
0new file mode 10064485new file mode 100644
index 0000000..54ec969
--- /dev/null
+++ b/hooks/charmhelpers/core/unitdata.py
@@ -0,0 +1,518 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3#
4# Copyright 2014-2015 Canonical Limited.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17#
18# Authors:
19# Kapil Thangavelu <kapil.foss@gmail.com>
20#
21"""
22Intro
23-----
24
25A simple way to store state in units. This provides a key value
26storage with support for versioned, transactional operation,
27and can calculate deltas from previous values to simplify unit logic
28when processing changes.
29
30
31Hook Integration
32----------------
33
34There are several extant frameworks for hook execution, including
35
36 - charmhelpers.core.hookenv.Hooks
37 - charmhelpers.core.services.ServiceManager
38
39The storage classes are framework agnostic, one simple integration is
40via the HookData contextmanager. It will record the current hook
41execution environment (including relation data, config data, etc.),
42setup a transaction and allow easy access to the changes from
43previously seen values. One consequence of the integration is the
44reservation of particular keys ('rels', 'unit', 'env', 'config',
45'charm_revisions') for their respective values.
46
47Here's a fully worked integration example using hookenv.Hooks::
48
49 from charmhelper.core import hookenv, unitdata
50
51 hook_data = unitdata.HookData()
52 db = unitdata.kv()
53 hooks = hookenv.Hooks()
54
55 @hooks.hook
56 def config_changed():
57 # Print all changes to configuration from previously seen
58 # values.
59 for changed, (prev, cur) in hook_data.conf.items():
60 print('config changed', changed,
61 'previous value', prev,
62 'current value', cur)
63
64 # Get some unit specific bookeeping
65 if not db.get('pkg_key'):
66 key = urllib.urlopen('https://example.com/pkg_key').read()
67 db.set('pkg_key', key)
68
69 # Directly access all charm config as a mapping.
70 conf = db.getrange('config', True)
71
72 # Directly access all relation data as a mapping
73 rels = db.getrange('rels', True)
74
75 if __name__ == '__main__':
76 with hook_data():
77 hook.execute()
78
79
80A more basic integration is via the hook_scope context manager which simply
81manages transaction scope (and records hook name, and timestamp)::
82
83 >>> from unitdata import kv
84 >>> db = kv()
85 >>> with db.hook_scope('install'):
86 ... # do work, in transactional scope.
87 ... db.set('x', 1)
88 >>> db.get('x')
89 1
90
91
92Usage
93-----
94
95Values are automatically json de/serialized to preserve basic typing
96and complex data struct capabilities (dicts, lists, ints, booleans, etc).
97
98Individual values can be manipulated via get/set::
99
100 >>> kv.set('y', True)
101 >>> kv.get('y')
102 True
103
104 # We can set complex values (dicts, lists) as a single key.
105 >>> kv.set('config', {'a': 1, 'b': True'})
106
107 # Also supports returning dictionaries as a record which
108 # provides attribute access.
109 >>> config = kv.get('config', record=True)
110 >>> config.b
111 True
112
113
114Groups of keys can be manipulated with update/getrange::
115
116 >>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
117 >>> kv.getrange('gui.', strip=True)
118 {'z': 1, 'y': 2}
119
120When updating values, its very helpful to understand which values
121have actually changed and how have they changed. The storage
122provides a delta method to provide for this::
123
124 >>> data = {'debug': True, 'option': 2}
125 >>> delta = kv.delta(data, 'config.')
126 >>> delta.debug.previous
127 None
128 >>> delta.debug.current
129 True
130 >>> delta
131 {'debug': (None, True), 'option': (None, 2)}
132
133Note the delta method does not persist the actual change, it needs to
134be explicitly saved via 'update' method::
135
136 >>> kv.update(data, 'config.')
137
138Values modified in the context of a hook scope retain historical values
139associated to the hookname.
140
141 >>> with db.hook_scope('config-changed'):
142 ... db.set('x', 42)
143 >>> db.gethistory('x')
144 [(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
145 (2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
146
147"""
148
149import collections
150import contextlib
151import datetime
152import itertools
153import json
154import os
155import pprint
156import sqlite3
157import sys
158
159__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
160
161
162class Storage(object):
163 """Simple key value database for local unit state within charms.
164
165 Modifications are not persisted unless :meth:`flush` is called.
166
167 To support dicts, lists, integer, floats, and booleans values
168 are automatically json encoded/decoded.
169 """
170 def __init__(self, path=None):
171 self.db_path = path
172 if path is None:
173 if 'UNIT_STATE_DB' in os.environ:
174 self.db_path = os.environ['UNIT_STATE_DB']
175 else:
176 self.db_path = os.path.join(
177 os.environ.get('CHARM_DIR', ''), '.unit-state.db')
178 self.conn = sqlite3.connect('%s' % self.db_path)
179 self.cursor = self.conn.cursor()
180 self.revision = None
181 self._closed = False
182 self._init()
183
184 def close(self):
185 if self._closed:
186 return
187 self.flush(False)
188 self.cursor.close()
189 self.conn.close()
190 self._closed = True
191
192 def get(self, key, default=None, record=False):
193 self.cursor.execute('select data from kv where key=?', [key])
194 result = self.cursor.fetchone()
195 if not result:
196 return default
197 if record:
198 return Record(json.loads(result[0]))
199 return json.loads(result[0])
200
201 def getrange(self, key_prefix, strip=False):
202 """
203 Get a range of keys starting with a common prefix as a mapping of
204 keys to values.
205
206 :param str key_prefix: Common prefix among all keys
207 :param bool strip: Optionally strip the common prefix from the key
208 names in the returned dict
209 :return dict: A (possibly empty) dict of key-value mappings
210 """
211 self.cursor.execute("select key, data from kv where key like ?",
212 ['%s%%' % key_prefix])
213 result = self.cursor.fetchall()
214
215 if not result:
216 return {}
217 if not strip:
218 key_prefix = ''
219 return dict([
220 (k[len(key_prefix):], json.loads(v)) for k, v in result])
221
222 def update(self, mapping, prefix=""):
223 """
224 Set the values of multiple keys at once.
225
226 :param dict mapping: Mapping of keys to values
227 :param str prefix: Optional prefix to apply to all keys in `mapping`
228 before setting
229 """
230 for k, v in mapping.items():
231 self.set("%s%s" % (prefix, k), v)
232
233 def unset(self, key):
234 """
235 Remove a key from the database entirely.
236 """
237 self.cursor.execute('delete from kv where key=?', [key])
238 if self.revision and self.cursor.rowcount:
239 self.cursor.execute(
240 'insert into kv_revisions values (?, ?, ?)',
241 [key, self.revision, json.dumps('DELETED')])
242
243 def unsetrange(self, keys=None, prefix=""):
244 """
245 Remove a range of keys starting with a common prefix, from the database
246 entirely.
247
248 :param list keys: List of keys to remove.
249 :param str prefix: Optional prefix to apply to all keys in ``keys``
250 before removing.
251 """
252 if keys is not None:
253 keys = ['%s%s' % (prefix, key) for key in keys]
254 self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
255 if self.revision and self.cursor.rowcount:
256 self.cursor.execute(
257 'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
258 list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
259 else:
260 self.cursor.execute('delete from kv where key like ?',
261 ['%s%%' % prefix])
262 if self.revision and self.cursor.rowcount:
263 self.cursor.execute(
264 'insert into kv_revisions values (?, ?, ?)',
265 ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
266
267 def set(self, key, value):
268 """
269 Set a value in the database.
270
271 :param str key: Key to set the value for
272 :param value: Any JSON-serializable value to be set
273 """
274 serialized = json.dumps(value)
275
276 self.cursor.execute('select data from kv where key=?', [key])
277 exists = self.cursor.fetchone()
278
279 # Skip mutations to the same value
280 if exists:
281 if exists[0] == serialized:
282 return value
283
284 if not exists:
285 self.cursor.execute(
286 'insert into kv (key, data) values (?, ?)',
287 (key, serialized))
288 else:
289 self.cursor.execute('''
290 update kv
291 set data = ?
292 where key = ?''', [serialized, key])
293
294 # Save
295 if not self.revision:
296 return value
297
298 self.cursor.execute(
299 'select 1 from kv_revisions where key=? and revision=?',
300 [key, self.revision])
301 exists = self.cursor.fetchone()
302
303 if not exists:
304 self.cursor.execute(
305 '''insert into kv_revisions (
306 revision, key, data) values (?, ?, ?)''',
307 (self.revision, key, serialized))
308 else:
309 self.cursor.execute(
310 '''
311 update kv_revisions
312 set data = ?
313 where key = ?
314 and revision = ?''',
315 [serialized, key, self.revision])
316
317 return value
318
319 def delta(self, mapping, prefix):
320 """
321 return a delta containing values that have changed.
322 """
323 previous = self.getrange(prefix, strip=True)
324 if not previous:
325 pk = set()
326 else:
327 pk = set(previous.keys())
328 ck = set(mapping.keys())
329 delta = DeltaSet()
330
331 # added
332 for k in ck.difference(pk):
333 delta[k] = Delta(None, mapping[k])
334
335 # removed
336 for k in pk.difference(ck):
337 delta[k] = Delta(previous[k], None)
338
339 # changed
340 for k in pk.intersection(ck):
341 c = mapping[k]
342 p = previous[k]
343 if c != p:
344 delta[k] = Delta(p, c)
345
346 return delta
347
348 @contextlib.contextmanager
349 def hook_scope(self, name=""):
350 """Scope all future interactions to the current hook execution
351 revision."""
352 assert not self.revision
353 self.cursor.execute(
354 'insert into hooks (hook, date) values (?, ?)',
355 (name or sys.argv[0],
356 datetime.datetime.utcnow().isoformat()))
357 self.revision = self.cursor.lastrowid
358 try:
359 yield self.revision
360 self.revision = None
361 except:
362 self.flush(False)
363 self.revision = None
364 raise
365 else:
366 self.flush()
367
368 def flush(self, save=True):
369 if save:
370 self.conn.commit()
371 elif self._closed:
372 return
373 else:
374 self.conn.rollback()
375
376 def _init(self):
377 self.cursor.execute('''
378 create table if not exists kv (
379 key text,
380 data text,
381 primary key (key)
382 )''')
383 self.cursor.execute('''
384 create table if not exists kv_revisions (
385 key text,
386 revision integer,
387 data text,
388 primary key (key, revision)
389 )''')
390 self.cursor.execute('''
391 create table if not exists hooks (
392 version integer primary key autoincrement,
393 hook text,
394 date text
395 )''')
396 self.conn.commit()
397
398 def gethistory(self, key, deserialize=False):
399 self.cursor.execute(
400 '''
401 select kv.revision, kv.key, kv.data, h.hook, h.date
402 from kv_revisions kv,
403 hooks h
404 where kv.key=?
405 and kv.revision = h.version
406 ''', [key])
407 if deserialize is False:
408 return self.cursor.fetchall()
409 return map(_parse_history, self.cursor.fetchall())
410
411 def debug(self, fh=sys.stderr):
412 self.cursor.execute('select * from kv')
413 pprint.pprint(self.cursor.fetchall(), stream=fh)
414 self.cursor.execute('select * from kv_revisions')
415 pprint.pprint(self.cursor.fetchall(), stream=fh)
416
417
418def _parse_history(d):
419 return (d[0], d[1], json.loads(d[2]), d[3],
420 datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
421
422
423class HookData(object):
424 """Simple integration for existing hook exec frameworks.
425
426 Records all unit information, and stores deltas for processing
427 by the hook.
428
429 Sample::
430
431 from charmhelper.core import hookenv, unitdata
432
433 changes = unitdata.HookData()
434 db = unitdata.kv()
435 hooks = hookenv.Hooks()
436
437 @hooks.hook
438 def config_changed():
439 # View all changes to configuration
440 for changed, (prev, cur) in changes.conf.items():
441 print('config changed', changed,
442 'previous value', prev,
443 'current value', cur)
444
445 # Get some unit specific bookeeping
446 if not db.get('pkg_key'):
447 key = urllib.urlopen('https://example.com/pkg_key').read()
448 db.set('pkg_key', key)
449
450 if __name__ == '__main__':
451 with changes():
452 hook.execute()
453
454 """
455 def __init__(self):
456 self.kv = kv()
457 self.conf = None
458 self.rels = None
459
460 @contextlib.contextmanager
461 def __call__(self):
462 from charmhelpers.core import hookenv
463 hook_name = hookenv.hook_name()
464
465 with self.kv.hook_scope(hook_name):
466 self._record_charm_version(hookenv.charm_dir())
467 delta_config, delta_relation = self._record_hook(hookenv)
468 yield self.kv, delta_config, delta_relation
469
470 def _record_charm_version(self, charm_dir):
471 # Record revisions.. charm revisions are meaningless
472 # to charm authors as they don't control the revision.
473 # so logic dependnent on revision is not particularly
474 # useful, however it is useful for debugging analysis.
475 charm_rev = open(
476 os.path.join(charm_dir, 'revision')).read().strip()
477 charm_rev = charm_rev or '0'
478 revs = self.kv.get('charm_revisions', [])
479 if charm_rev not in revs:
480 revs.append(charm_rev.strip() or '0')
481 self.kv.set('charm_revisions', revs)
482
483 def _record_hook(self, hookenv):
484 data = hookenv.execution_environment()
485 self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
486 self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
487 self.kv.set('env', dict(data['env']))
488 self.kv.set('unit', data['unit'])
489 self.kv.set('relid', data.get('relid'))
490 return conf_delta, rels_delta
491
492
493class Record(dict):
494
495 __slots__ = ()
496
497 def __getattr__(self, k):
498 if k in self:
499 return self[k]
500 raise AttributeError(k)
501
502
503class DeltaSet(Record):
504
505 __slots__ = ()
506
507
508Delta = collections.namedtuple('Delta', ['previous', 'current'])
509
510
511_KV = None
512
513
514def kv():
515 global _KV
516 if _KV is None:
517 _KV = Storage()
518 return _KV
diff --git a/hooks/charmhelpers/fetch/__init__.py b/hooks/charmhelpers/fetch/__init__.py
0new file mode 100644519new file mode 100644
index 0000000..ec5e0fe
--- /dev/null
+++ b/hooks/charmhelpers/fetch/__init__.py
@@ -0,0 +1,197 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import importlib
16from charmhelpers.osplatform import get_platform
17from yaml import safe_load
18from charmhelpers.core.hookenv import (
19 config,
20 log,
21)
22
23import six
24if six.PY3:
25 from urllib.parse import urlparse, urlunparse
26else:
27 from urlparse import urlparse, urlunparse
28
29
30# The order of this list is very important. Handlers should be listed in from
31# least- to most-specific URL matching.
32FETCH_HANDLERS = (
33 'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
34 'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
35 'charmhelpers.fetch.giturl.GitUrlFetchHandler',
36)
37
38
39class SourceConfigError(Exception):
40 pass
41
42
43class UnhandledSource(Exception):
44 pass
45
46
47class AptLockError(Exception):
48 pass
49
50
51class BaseFetchHandler(object):
52
53 """Base class for FetchHandler implementations in fetch plugins"""
54
55 def can_handle(self, source):
56 """Returns True if the source can be handled. Otherwise returns
57 a string explaining why it cannot"""
58 return "Wrong source type"
59
60 def install(self, source):
61 """Try to download and unpack the source. Return the path to the
62 unpacked files or raise UnhandledSource."""
63 raise UnhandledSource("Wrong source type {}".format(source))
64
65 def parse_url(self, url):
66 return urlparse(url)
67
68 def base_url(self, url):
69 """Return url without querystring or fragment"""
70 parts = list(self.parse_url(url))
71 parts[4:] = ['' for i in parts[4:]]
72 return urlunparse(parts)
73
74
75__platform__ = get_platform()
76module = "charmhelpers.fetch.%s" % __platform__
77fetch = importlib.import_module(module)
78
79filter_installed_packages = fetch.filter_installed_packages
80install = fetch.install
81upgrade = fetch.upgrade
82update = fetch.update
83purge = fetch.purge
84add_source = fetch.add_source
85
86if __platform__ == "ubuntu":
87 apt_cache = fetch.apt_cache
88 apt_install = fetch.install
89 apt_update = fetch.update
90 apt_upgrade = fetch.upgrade
91 apt_purge = fetch.purge
92 apt_mark = fetch.apt_mark
93 apt_hold = fetch.apt_hold
94 apt_unhold = fetch.apt_unhold
95 get_upstream_version = fetch.get_upstream_version
96elif __platform__ == "centos":
97 yum_search = fetch.yum_search
98
99
100def configure_sources(update=False,
101 sources_var='install_sources',
102 keys_var='install_keys'):
103 """Configure multiple sources from charm configuration.
104
105 The lists are encoded as yaml fragments in the configuration.
106 The fragment needs to be included as a string. Sources and their
107 corresponding keys are of the types supported by add_source().
108
109 Example config:
110 install_sources: |
111 - "ppa:foo"
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches