diff --git a/charm-helpers-hooks.yaml b/charm-helpers-hooks.yaml
index 8f5373ec..c8c54766 100644
--- a/charm-helpers-hooks.yaml
+++ b/charm-helpers-hooks.yaml
@@ -2,6 +2,7 @@ branch: lp:charm-helpers
destination: hooks/charmhelpers
include:
- core
+ - cli
- fetch
- contrib.storage.linux:
- utils
diff --git a/hooks/charmhelpers/cli/__init__.py b/hooks/charmhelpers/cli/__init__.py
new file mode 100644
index 00000000..7118daf5
--- /dev/null
+++ b/hooks/charmhelpers/cli/__init__.py
@@ -0,0 +1,195 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+import inspect
+import argparse
+import sys
+
+from six.moves import zip
+
+from charmhelpers.core import unitdata
+
+
+class OutputFormatter(object):
+ def __init__(self, outfile=sys.stdout):
+ self.formats = (
+ "raw",
+ "json",
+ "py",
+ "yaml",
+ "csv",
+ "tab",
+ )
+ self.outfile = outfile
+
+ def add_arguments(self, argument_parser):
+ formatgroup = argument_parser.add_mutually_exclusive_group()
+ choices = self.supported_formats
+ formatgroup.add_argument("--format", metavar='FMT',
+ help="Select output format for returned data, "
+ "where FMT is one of: {}".format(choices),
+ choices=choices, default='raw')
+ for fmt in self.formats:
+ fmtfunc = getattr(self, fmt)
+ formatgroup.add_argument("-{}".format(fmt[0]),
+ "--{}".format(fmt), action='store_const',
+ const=fmt, dest='format',
+ help=fmtfunc.__doc__)
+
+ @property
+ def supported_formats(self):
+ return self.formats
+
+ def raw(self, output):
+ """Output data as raw string (default)"""
+ if isinstance(output, (list, tuple)):
+ output = '\n'.join(map(str, output))
+ self.outfile.write(str(output))
+
+ def py(self, output):
+ """Output data as a nicely-formatted python data structure"""
+ import pprint
+ pprint.pprint(output, stream=self.outfile)
+
+ def json(self, output):
+ """Output data in JSON format"""
+ import json
+ json.dump(output, self.outfile)
+
+ def yaml(self, output):
+ """Output data in YAML format"""
+ import yaml
+ yaml.safe_dump(output, self.outfile)
+
+ def csv(self, output):
+ """Output data as excel-compatible CSV"""
+ import csv
+ csvwriter = csv.writer(self.outfile)
+ csvwriter.writerows(output)
+
+ def tab(self, output):
+ """Output data in excel-compatible tab-delimited format"""
+ import csv
+ csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
+ csvwriter.writerows(output)
+
+ def format_output(self, output, fmt='raw'):
+ fmtfunc = getattr(self, fmt)
+ fmtfunc(output)
+
+
+class CommandLine(object):
+ argument_parser = None
+ subparsers = None
+ formatter = None
+ exit_code = 0
+
+ def __init__(self):
+ if not self.argument_parser:
+ self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
+ if not self.formatter:
+ self.formatter = OutputFormatter()
+ self.formatter.add_arguments(self.argument_parser)
+ if not self.subparsers:
+ self.subparsers = self.argument_parser.add_subparsers(help='Commands')
+
+ def subcommand(self, command_name=None):
+ """
+ Decorate a function as a subcommand. Use its arguments as the
+ command-line arguments"""
+ def wrapper(decorated):
+ cmd_name = command_name or decorated.__name__
+ subparser = self.subparsers.add_parser(cmd_name,
+ description=decorated.__doc__)
+ for args, kwargs in describe_arguments(decorated):
+ subparser.add_argument(*args, **kwargs)
+ subparser.set_defaults(func=decorated)
+ return decorated
+ return wrapper
+
+ def test_command(self, decorated):
+ """
+ Subcommand is a boolean test function, so bool return values should be
+ converted to a 0/1 exit code.
+ """
+ decorated._cli_test_command = True
+ return decorated
+
+ def no_output(self, decorated):
+ """
+ Subcommand is not expected to return a value, so don't print a spurious None.
+ """
+ decorated._cli_no_output = True
+ return decorated
+
+ def subcommand_builder(self, command_name, description=None):
+ """
+ Decorate a function that builds a subcommand. Builders should accept a
+ single argument (the subparser instance) and return the function to be
+ run as the command."""
+ def wrapper(decorated):
+ subparser = self.subparsers.add_parser(command_name)
+ func = decorated(subparser)
+ subparser.set_defaults(func=func)
+ subparser.description = description or func.__doc__
+ return wrapper
+
+ def run(self):
+ "Run cli, processing arguments and executing subcommands."
+ arguments = self.argument_parser.parse_args()
+ argspec = inspect.getargspec(arguments.func)
+ vargs = []
+ kwargs = {}
+ for arg in argspec.args:
+ vargs.append(getattr(arguments, arg))
+ if argspec.varargs:
+ vargs.extend(getattr(arguments, argspec.varargs))
+ if argspec.keywords:
+ for kwarg in argspec.keywords.items():
+ kwargs[kwarg] = getattr(arguments, kwarg)
+ output = arguments.func(*vargs, **kwargs)
+ if getattr(arguments.func, '_cli_test_command', False):
+ self.exit_code = 0 if output else 1
+ output = ''
+ if getattr(arguments.func, '_cli_no_output', False):
+ output = ''
+ self.formatter.format_output(output, arguments.format)
+ if unitdata._KV:
+ unitdata._KV.flush()
+
+
+cmdline = CommandLine()
+
+
+def describe_arguments(func):
+ """
+ Analyze a function's signature and return a data structure suitable for
+ passing in as arguments to an argparse parser's add_argument() method."""
+
+ argspec = inspect.getargspec(func)
+ # we should probably raise an exception somewhere if func includes **kwargs
+ if argspec.defaults:
+ positional_args = argspec.args[:-len(argspec.defaults)]
+ keyword_names = argspec.args[-len(argspec.defaults):]
+ for arg, default in zip(keyword_names, argspec.defaults):
+ yield ('--{}'.format(arg),), {'default': default}
+ else:
+ positional_args = argspec.args
+
+ for arg in positional_args:
+ yield (arg,), {}
+ if argspec.varargs:
+ yield (argspec.varargs,), {'nargs': '*'}
diff --git a/hooks/charmhelpers/cli/benchmark.py b/hooks/charmhelpers/cli/benchmark.py
new file mode 100644
index 00000000..b23c16ce
--- /dev/null
+++ b/hooks/charmhelpers/cli/benchmark.py
@@ -0,0 +1,36 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+from . import cmdline
+from charmhelpers.contrib.benchmark import Benchmark
+
+
+@cmdline.subcommand(command_name='benchmark-start')
+def start():
+ Benchmark.start()
+
+
+@cmdline.subcommand(command_name='benchmark-finish')
+def finish():
+ Benchmark.finish()
+
+
+@cmdline.subcommand_builder('benchmark-composite', description="Set the benchmark composite score")
+def service(subparser):
+ subparser.add_argument("value", help="The composite score.")
+ subparser.add_argument("units", help="The units the composite score represents, i.e., 'reads/sec'.")
+ subparser.add_argument("direction", help="'asc' if a lower score is better, 'desc' if a higher score is better.")
+ return Benchmark.set_composite_score
diff --git a/hooks/charmhelpers/cli/commands.py b/hooks/charmhelpers/cli/commands.py
new file mode 100644
index 00000000..443ff05d
--- /dev/null
+++ b/hooks/charmhelpers/cli/commands.py
@@ -0,0 +1,32 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+"""
+This module loads sub-modules into the python runtime so they can be
+discovered via the inspect module. In order to prevent flake8 from (rightfully)
+telling us these are unused modules, throw a ' # noqa' at the end of each import
+so that the warning is suppressed.
+"""
+
+from . import CommandLine # noqa
+
+"""
+Import the sub-modules which have decorated subcommands to register with chlp.
+"""
+import host # noqa
+import benchmark # noqa
+import unitdata # noqa
+from charmhelpers.core import hookenv # noqa
diff --git a/hooks/charmhelpers/cli/host.py b/hooks/charmhelpers/cli/host.py
new file mode 100644
index 00000000..58e78d6b
--- /dev/null
+++ b/hooks/charmhelpers/cli/host.py
@@ -0,0 +1,31 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+from . import cmdline
+from charmhelpers.core import host
+
+
+@cmdline.subcommand()
+def mounts():
+ "List mounts"
+ return host.mounts()
+
+
+@cmdline.subcommand_builder('service', description="Control system services")
+def service(subparser):
+ subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
+ subparser.add_argument("service_name", help="Name of the service to control")
+ return host.service
diff --git a/hooks/charmhelpers/cli/unitdata.py b/hooks/charmhelpers/cli/unitdata.py
new file mode 100644
index 00000000..d1cd95bf
--- /dev/null
+++ b/hooks/charmhelpers/cli/unitdata.py
@@ -0,0 +1,39 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+from . import cmdline
+from charmhelpers.core import unitdata
+
+
+@cmdline.subcommand_builder('unitdata', description="Store and retrieve data")
+def unitdata_cmd(subparser):
+ nested = subparser.add_subparsers()
+ get_cmd = nested.add_parser('get', help='Retrieve data')
+ get_cmd.add_argument('key', help='Key to retrieve the value of')
+ get_cmd.set_defaults(action='get', value=None)
+ set_cmd = nested.add_parser('set', help='Store data')
+ set_cmd.add_argument('key', help='Key to set')
+ set_cmd.add_argument('value', help='Value to store')
+ set_cmd.set_defaults(action='set')
+
+ def _unitdata_cmd(action, key, value):
+ if action == 'get':
+ return unitdata.kv().get(key)
+ elif action == 'set':
+ unitdata.kv().set(key, value)
+ unitdata.kv().flush()
+ return ''
+ return _unitdata_cmd
diff --git a/hooks/charmhelpers/contrib/storage/linux/utils.py b/hooks/charmhelpers/contrib/storage/linux/utils.py
index c8373b72..e2769e49 100644
--- a/hooks/charmhelpers/contrib/storage/linux/utils.py
+++ b/hooks/charmhelpers/contrib/storage/linux/utils.py
@@ -67,4 +67,4 @@ def is_device_mounted(device):
out = check_output(['mount']).decode('UTF-8')
if is_partition:
return bool(re.search(device + r"\b", out))
- return bool(re.search(device + r"[0-9]+\b", out))
+ return bool(re.search(device + r"[0-9]*\b", out))
diff --git a/hooks/charmhelpers/core/files.py b/hooks/charmhelpers/core/files.py
new file mode 100644
index 00000000..0f12d321
--- /dev/null
+++ b/hooks/charmhelpers/core/files.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+__author__ = 'Jorge Niedbalski '
+
+import os
+import subprocess
+
+
+def sed(filename, before, after, flags='g'):
+ """
+ Search and replaces the given pattern on filename.
+
+ :param filename: relative or absolute file path.
+ :param before: expression to be replaced (see 'man sed')
+ :param after: expression to replace with (see 'man sed')
+ :param flags: sed-compatible regex flags in example, to make
+ the search and replace case insensitive, specify ``flags="i"``.
+ The ``g`` flag is always specified regardless, so you do not
+ need to remember to include it when overriding this parameter.
+ :returns: If the sed command exit code was zero then return,
+ otherwise raise CalledProcessError.
+ """
+ expression = r's/{0}/{1}/{2}'.format(before,
+ after, flags)
+
+ return subprocess.check_call(["sed", "-i", "-r", "-e",
+ expression,
+ os.path.expanduser(filename)])
diff --git a/hooks/charmhelpers/core/hookenv.py b/hooks/charmhelpers/core/hookenv.py
index 0add16d4..18860f59 100644
--- a/hooks/charmhelpers/core/hookenv.py
+++ b/hooks/charmhelpers/core/hookenv.py
@@ -21,6 +21,7 @@
# Charm Helpers Developers
from __future__ import print_function
+import copy
from distutils.version import LooseVersion
from functools import wraps
import glob
@@ -33,6 +34,23 @@ import errno
import tempfile
from subprocess import CalledProcessError
+try:
+ from charmhelpers.cli import cmdline
+except ImportError as e:
+ # due to the anti-pattern of partially synching charmhelpers directly
+ # into charms, it's possible that charmhelpers.cli is not available;
+ # if that's the case, they don't really care about using the cli anyway,
+ # so mock it out
+ if str(e) == 'No module named cli':
+ class cmdline(object):
+ @classmethod
+ def subcommand(cls, *args, **kwargs):
+ def _wrap(func):
+ return func
+ return _wrap
+ else:
+ raise
+
import six
if not six.PY3:
from UserDict import UserDict
@@ -172,9 +190,20 @@ def relation_type():
return os.environ.get('JUJU_RELATION', None)
-def relation_id():
- """The relation ID for the current relation hook"""
- return os.environ.get('JUJU_RELATION_ID', None)
+@cmdline.subcommand()
+@cached
+def relation_id(relation_name=None, service_or_unit=None):
+ """The relation ID for the current or a specified relation"""
+ if not relation_name and not service_or_unit:
+ return os.environ.get('JUJU_RELATION_ID', None)
+ elif relation_name and service_or_unit:
+ service_name = service_or_unit.split('/')[0]
+ for relid in relation_ids(relation_name):
+ remote_service = remote_service_name(relid)
+ if remote_service == service_name:
+ return relid
+ else:
+ raise ValueError('Must specify neither or both of relation_name and service_or_unit')
def local_unit():
@@ -187,14 +216,27 @@ def remote_unit():
return os.environ.get('JUJU_REMOTE_UNIT', None)
+@cmdline.subcommand()
def service_name():
"""The name service group this unit belongs to"""
return local_unit().split('/')[0]
+@cmdline.subcommand()
+@cached
+def remote_service_name(relid=None):
+ """The remote service name for a given relation-id (or the current relation)"""
+ if relid is None:
+ unit = remote_unit()
+ else:
+ units = related_units(relid)
+ unit = units[0] if units else None
+ return unit.split('/')[0] if unit else None
+
+
def hook_name():
"""The name of the currently executing hook"""
- return os.path.basename(sys.argv[0])
+ return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
class Config(dict):
@@ -263,7 +305,7 @@ class Config(dict):
self.path = path or self.path
with open(self.path) as f:
self._prev_dict = json.load(f)
- for k, v in self._prev_dict.items():
+ for k, v in copy.deepcopy(self._prev_dict).items():
if k not in self:
self[k] = v
@@ -467,6 +509,63 @@ def relation_types():
return rel_types
+@cached
+def relation_to_interface(relation_name):
+ """
+ Given the name of a relation, return the interface that relation uses.
+
+ :returns: The interface name, or ``None``.
+ """
+ return relation_to_role_and_interface(relation_name)[1]
+
+
+@cached
+def relation_to_role_and_interface(relation_name):
+ """
+ Given the name of a relation, return the role and the name of the interface
+ that relation uses (where role is one of ``provides``, ``requires``, or ``peer``).
+
+ :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
+ """
+ _metadata = metadata()
+ for role in ('provides', 'requires', 'peer'):
+ interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
+ if interface:
+ return role, interface
+ return None, None
+
+
+@cached
+def role_and_interface_to_relations(role, interface_name):
+ """
+ Given a role and interface name, return a list of relation names for the
+ current charm that use that interface under that role (where role is one
+ of ``provides``, ``requires``, or ``peer``).
+
+ :returns: A list of relation names.
+ """
+ _metadata = metadata()
+ results = []
+ for relation_name, relation in _metadata.get(role, {}).items():
+ if relation['interface'] == interface_name:
+ results.append(relation_name)
+ return results
+
+
+@cached
+def interface_to_relations(interface_name):
+ """
+ Given an interface, return a list of relation names for the current
+ charm that use that interface.
+
+ :returns: A list of relation names.
+ """
+ results = []
+ for role in ('provides', 'requires', 'peer'):
+ results.extend(role_and_interface_to_relations(role, interface_name))
+ return results
+
+
@cached
def charm_name():
"""Get the name of the current charm as is specified on metadata.yaml"""
@@ -643,6 +742,21 @@ def action_fail(message):
subprocess.check_call(['action-fail', message])
+def action_name():
+ """Get the name of the currently executing action."""
+ return os.environ.get('JUJU_ACTION_NAME')
+
+
+def action_uuid():
+ """Get the UUID of the currently executing action."""
+ return os.environ.get('JUJU_ACTION_UUID')
+
+
+def action_tag():
+ """Get the tag for the currently executing action."""
+ return os.environ.get('JUJU_ACTION_TAG')
+
+
def status_set(workload_state, message):
"""Set the workload state with a message
@@ -761,6 +875,7 @@ def atstart(callback, *args, **kwargs):
This is useful for modules and classes to perform initialization
and inject behavior. In particular:
+
- Run common code before all of your hooks, such as logging
the hook name or interesting relation data.
- Defer object or module initialization that requires a hook
diff --git a/hooks/charmhelpers/core/host.py b/hooks/charmhelpers/core/host.py
index 901a4cfe..8ae8ef86 100644
--- a/hooks/charmhelpers/core/host.py
+++ b/hooks/charmhelpers/core/host.py
@@ -63,6 +63,36 @@ def service_reload(service_name, restart_on_failure=False):
return service_result
+def service_pause(service_name, init_dir=None):
+ """Pause a system service.
+
+ Stop it, and prevent it from starting again at boot."""
+ if init_dir is None:
+ init_dir = "/etc/init"
+ stopped = service_stop(service_name)
+ # XXX: Support systemd too
+ override_path = os.path.join(
+ init_dir, '{}.conf.override'.format(service_name))
+ with open(override_path, 'w') as fh:
+ fh.write("manual\n")
+ return stopped
+
+
+def service_resume(service_name, init_dir=None):
+ """Resume a system service.
+
+ Reenable starting again at boot. Start the service"""
+ # XXX: Support systemd too
+ if init_dir is None:
+ init_dir = "/etc/init"
+ override_path = os.path.join(
+ init_dir, '{}.conf.override'.format(service_name))
+ if os.path.exists(override_path):
+ os.unlink(override_path)
+ started = service_start(service_name)
+ return started
+
+
def service(action, service_name):
"""Control a system service"""
cmd = ['service', service_name, action]
@@ -140,11 +170,7 @@ def add_group(group_name, system_group=False):
def add_user_to_group(username, group):
"""Add a user to a group"""
- cmd = [
- 'gpasswd', '-a',
- username,
- group
- ]
+ cmd = ['gpasswd', '-a', username, group]
log("Adding user {} to group {}".format(username, group))
subprocess.check_call(cmd)
diff --git a/hooks/charmhelpers/core/services/helpers.py b/hooks/charmhelpers/core/services/helpers.py
index 3eb5fb44..8005c415 100644
--- a/hooks/charmhelpers/core/services/helpers.py
+++ b/hooks/charmhelpers/core/services/helpers.py
@@ -239,12 +239,12 @@ class TemplateCallback(ManagerCallback):
action.
:param str source: The template source file, relative to
- `$CHARM_DIR/templates`
-
+ `$CHARM_DIR/templates`
:param str target: The target to write the rendered template to
:param str owner: The owner of the rendered file
:param str group: The group of the rendered file
:param int perms: The permissions of the rendered file
+
"""
def __init__(self, source, target,
owner='root', group='root', perms=0o444):
diff --git a/hooks/charmhelpers/core/unitdata.py b/hooks/charmhelpers/core/unitdata.py
index 406a35c5..338104e0 100644
--- a/hooks/charmhelpers/core/unitdata.py
+++ b/hooks/charmhelpers/core/unitdata.py
@@ -152,6 +152,7 @@ associated to the hookname.
import collections
import contextlib
import datetime
+import itertools
import json
import os
import pprint
@@ -164,8 +165,7 @@ __author__ = 'Kapil Thangavelu '
class Storage(object):
"""Simple key value database for local unit state within charms.
- Modifications are automatically committed at hook exit. That's
- currently regardless of exit code.
+ Modifications are not persisted unless :meth:`flush` is called.
To support dicts, lists, integer, floats, and booleans values
are automatically json encoded/decoded.
@@ -173,8 +173,11 @@ class Storage(object):
def __init__(self, path=None):
self.db_path = path
if path is None:
- self.db_path = os.path.join(
- os.environ.get('CHARM_DIR', ''), '.unit-state.db')
+ if 'UNIT_STATE_DB' in os.environ:
+ self.db_path = os.environ['UNIT_STATE_DB']
+ else:
+ self.db_path = os.path.join(
+ os.environ.get('CHARM_DIR', ''), '.unit-state.db')
self.conn = sqlite3.connect('%s' % self.db_path)
self.cursor = self.conn.cursor()
self.revision = None
@@ -189,15 +192,8 @@ class Storage(object):
self.conn.close()
self._closed = True
- def _scoped_query(self, stmt, params=None):
- if params is None:
- params = []
- return stmt, params
-
def get(self, key, default=None, record=False):
- self.cursor.execute(
- *self._scoped_query(
- 'select data from kv where key=?', [key]))
+ self.cursor.execute('select data from kv where key=?', [key])
result = self.cursor.fetchone()
if not result:
return default
@@ -206,33 +202,81 @@ class Storage(object):
return json.loads(result[0])
def getrange(self, key_prefix, strip=False):
- stmt = "select key, data from kv where key like '%s%%'" % key_prefix
- self.cursor.execute(*self._scoped_query(stmt))
+ """
+ Get a range of keys starting with a common prefix as a mapping of
+ keys to values.
+
+ :param str key_prefix: Common prefix among all keys
+ :param bool strip: Optionally strip the common prefix from the key
+ names in the returned dict
+ :return dict: A (possibly empty) dict of key-value mappings
+ """
+ self.cursor.execute("select key, data from kv where key like ?",
+ ['%s%%' % key_prefix])
result = self.cursor.fetchall()
if not result:
- return None
+ return {}
if not strip:
key_prefix = ''
return dict([
(k[len(key_prefix):], json.loads(v)) for k, v in result])
def update(self, mapping, prefix=""):
+ """
+ Set the values of multiple keys at once.
+
+ :param dict mapping: Mapping of keys to values
+ :param str prefix: Optional prefix to apply to all keys in `mapping`
+ before setting
+ """
for k, v in mapping.items():
self.set("%s%s" % (prefix, k), v)
def unset(self, key):
+ """
+ Remove a key from the database entirely.
+ """
self.cursor.execute('delete from kv where key=?', [key])
if self.revision and self.cursor.rowcount:
self.cursor.execute(
'insert into kv_revisions values (?, ?, ?)',
[key, self.revision, json.dumps('DELETED')])
+ def unsetrange(self, keys=None, prefix=""):
+ """
+ Remove a range of keys starting with a common prefix, from the database
+ entirely.
+
+ :param list keys: List of keys to remove.
+ :param str prefix: Optional prefix to apply to all keys in ``keys``
+ before removing.
+ """
+ if keys is not None:
+ keys = ['%s%s' % (prefix, key) for key in keys]
+ self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
+ if self.revision and self.cursor.rowcount:
+ self.cursor.execute(
+ 'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
+ list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
+ else:
+ self.cursor.execute('delete from kv where key like ?',
+ ['%s%%' % prefix])
+ if self.revision and self.cursor.rowcount:
+ self.cursor.execute(
+ 'insert into kv_revisions values (?, ?, ?)',
+ ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
+
def set(self, key, value):
+ """
+ Set a value in the database.
+
+ :param str key: Key to set the value for
+ :param value: Any JSON-serializable value to be set
+ """
serialized = json.dumps(value)
- self.cursor.execute(
- 'select data from kv where key=?', [key])
+ self.cursor.execute('select data from kv where key=?', [key])
exists = self.cursor.fetchone()
# Skip mutations to the same value
diff --git a/hooks/charmhelpers/fetch/__init__.py b/hooks/charmhelpers/fetch/__init__.py
index 9a1a2515..0a3bb969 100644
--- a/hooks/charmhelpers/fetch/__init__.py
+++ b/hooks/charmhelpers/fetch/__init__.py
@@ -215,9 +215,9 @@ def apt_purge(packages, fatal=False):
_run_apt_command(cmd, fatal)
-def apt_hold(packages, fatal=False):
- """Hold one or more packages"""
- cmd = ['apt-mark', 'hold']
+def apt_mark(packages, mark, fatal=False):
+ """Flag one or more packages using apt-mark"""
+ cmd = ['apt-mark', mark]
if isinstance(packages, six.string_types):
cmd.append(packages)
else:
@@ -225,9 +225,17 @@ def apt_hold(packages, fatal=False):
log("Holding {}".format(packages))
if fatal:
- subprocess.check_call(cmd)
+ subprocess.check_call(cmd, universal_newlines=True)
else:
- subprocess.call(cmd)
+ subprocess.call(cmd, universal_newlines=True)
+
+
+def apt_hold(packages, fatal=False):
+ return apt_mark(packages, 'hold', fatal=fatal)
+
+
+def apt_unhold(packages, fatal=False):
+ return apt_mark(packages, 'unhold', fatal=fatal)
def add_source(source, key=None):
@@ -370,8 +378,9 @@ def install_remote(source, *args, **kwargs):
for handler in handlers:
try:
installed_to = handler.install(source, *args, **kwargs)
- except UnhandledSource:
- pass
+ except UnhandledSource as e:
+ log('Install source attempt unsuccessful: {}'.format(e),
+ level='WARNING')
if not installed_to:
raise UnhandledSource("No handler found for source {}".format(source))
return installed_to
diff --git a/hooks/charmhelpers/fetch/archiveurl.py b/hooks/charmhelpers/fetch/archiveurl.py
index 8dfce505..efd7f9f0 100644
--- a/hooks/charmhelpers/fetch/archiveurl.py
+++ b/hooks/charmhelpers/fetch/archiveurl.py
@@ -77,6 +77,8 @@ class ArchiveUrlFetchHandler(BaseFetchHandler):
def can_handle(self, source):
url_parts = self.parse_url(source)
if url_parts.scheme not in ('http', 'https', 'ftp', 'file'):
+ # XXX: Why is this returning a boolean and a string? It's
+ # doomed to fail since "bool(can_handle('foo://'))" will be True.
return "Wrong source type"
if get_archive_handler(self.base_url(source)):
return True
@@ -155,7 +157,11 @@ class ArchiveUrlFetchHandler(BaseFetchHandler):
else:
algorithms = hashlib.algorithms_available
if key in algorithms:
- check_hash(dld_file, value, key)
+ if len(value) != 1:
+ raise TypeError(
+ "Expected 1 hash value, not %d" % len(value))
+ expected = value[0]
+ check_hash(dld_file, expected, key)
if checksum:
check_hash(dld_file, checksum, hash_type)
return extract(dld_file, dest)
diff --git a/hooks/charmhelpers/fetch/giturl.py b/hooks/charmhelpers/fetch/giturl.py
index ddc25b7e..f023b26d 100644
--- a/hooks/charmhelpers/fetch/giturl.py
+++ b/hooks/charmhelpers/fetch/giturl.py
@@ -67,7 +67,7 @@ class GitUrlFetchHandler(BaseFetchHandler):
try:
self.clone(source, dest_dir, branch, depth)
except GitCommandError as e:
- raise UnhandledSource(e.message)
+ raise UnhandledSource(e)
except OSError as e:
raise UnhandledSource(e.strerror)
return dest_dir