From df0bb6080333521418c9fa740f7a46688142304b Mon Sep 17 00:00:00 2001 From: Ryan Beisner Date: Thu, 25 May 2017 11:05:28 -0500 Subject: [PATCH] Resync charm-helpers for sources repo ports fix Change-Id: I9c562c0e23fcde2745794fca221c210223874241 Closes-bug: #1611134 --- charmhelpers/__init__.py | 61 +++++ charmhelpers/contrib/charmsupport/nrpe.py | 7 + charmhelpers/contrib/openstack/utils.py | 200 +++++---------- charmhelpers/core/host.py | 2 + charmhelpers/fetch/__init__.py | 26 +- charmhelpers/fetch/centos.py | 2 +- charmhelpers/fetch/ubuntu.py | 300 ++++++++++++++++++---- tests/charmhelpers/__init__.py | 61 +++++ 8 files changed, 458 insertions(+), 201 deletions(-) diff --git a/charmhelpers/__init__.py b/charmhelpers/__init__.py index 4886788..e7aa471 100644 --- a/charmhelpers/__init__.py +++ b/charmhelpers/__init__.py @@ -14,6 +14,11 @@ # Bootstrap charm-helpers, installing its dependencies if necessary using # only standard libraries. +from __future__ import print_function +from __future__ import absolute_import + +import functools +import inspect import subprocess import sys @@ -34,3 +39,59 @@ except ImportError: else: subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml']) import yaml # flake8: noqa + + +# Holds a list of mapping of mangled function names that have been deprecated +# using the @deprecate decorator below. This is so that the warning is only +# printed once for each usage of the function. +__deprecated_functions = {} + + +def deprecate(warning, date=None, log=None): + """Add a deprecation warning the first time the function is used. + The date, which is a string in semi-ISO8660 format indicate the year-month + that the function is officially going to be removed. + + usage: + + @deprecate('use core/fetch/add_source() instead', '2017-04') + def contributed_add_source_thing(...): + ... + + And it then prints to the log ONCE that the function is deprecated. + The reason for passing the logging function (log) is so that hookenv.log + can be used for a charm if needed. + + :param warning: String to indicat where it has moved ot. + :param date: optional sting, in YYYY-MM format to indicate when the + function will definitely (probably) be removed. + :param log: The log function to call to log. If not, logs to stdout + """ + def wrap(f): + + @functools.wraps(f) + def wrapped_f(*args, **kwargs): + try: + module = inspect.getmodule(f) + file = inspect.getsourcefile(f) + lines = inspect.getsourcelines(f) + f_name = "{}-{}-{}..{}-{}".format( + module.__name__, file, lines[0], lines[-1], f.__name__) + except (IOError, TypeError): + # assume it was local, so just use the name of the function + f_name = f.__name__ + if f_name not in __deprecated_functions: + __deprecated_functions[f_name] = True + s = "DEPRECATION WARNING: Function {} is being removed".format( + f.__name__) + if date: + s = "{} on/around {}".format(s, date) + if warning: + s = "{} : {}".format(s, warning) + if log: + log(s) + else: + print(s) + return f(*args, **kwargs) + return wrapped_f + return wrap diff --git a/charmhelpers/contrib/charmsupport/nrpe.py b/charmhelpers/contrib/charmsupport/nrpe.py index 8240249..424b7f7 100644 --- a/charmhelpers/contrib/charmsupport/nrpe.py +++ b/charmhelpers/contrib/charmsupport/nrpe.py @@ -193,6 +193,13 @@ define service {{ nrpe_check_file = self._get_check_filename() with open(nrpe_check_file, 'w') as nrpe_check_config: nrpe_check_config.write("# check {}\n".format(self.shortname)) + if nagios_servicegroups: + nrpe_check_config.write( + "# The following header was added automatically by juju\n") + nrpe_check_config.write( + "# Modifying it will affect nagios monitoring and alerting\n") + nrpe_check_config.write( + "# servicegroups: {}\n".format(nagios_servicegroups)) nrpe_check_config.write("command[{}]={}\n".format( self.command, self.check_cmd)) diff --git a/charmhelpers/contrib/openstack/utils.py b/charmhelpers/contrib/openstack/utils.py index 161c786..1eaab95 100644 --- a/charmhelpers/contrib/openstack/utils.py +++ b/charmhelpers/contrib/openstack/utils.py @@ -26,11 +26,12 @@ import functools import shutil import six -import tempfile import traceback import uuid import yaml +from charmhelpers import deprecate + from charmhelpers.contrib.network import ip from charmhelpers.core import unitdata @@ -41,7 +42,6 @@ from charmhelpers.core.hookenv import ( config, log as juju_log, charm_dir, - DEBUG, INFO, ERROR, related_units, @@ -82,9 +82,12 @@ from charmhelpers.core.host import ( restart_on_change_helper, ) from charmhelpers.fetch import ( - apt_install, apt_cache, install_remote, + import_key as fetch_import_key, + add_source as fetch_add_source, + SourceConfigError, + GPGKeyError, get_upstream_version ) from charmhelpers.contrib.storage.linux.utils import is_block_device, zap_disk @@ -469,13 +472,14 @@ def get_os_version_package(pkg, fatal=True): # error_out(e) -os_rel = None +# Module local cache variable for the os_release. +_os_rel = None def reset_os_release(): '''Unset the cached os_release version''' - global os_rel - os_rel = None + global _os_rel + _os_rel = None def os_release(package, base='essex', reset_cache=False): @@ -489,150 +493,77 @@ def os_release(package, base='essex', reset_cache=False): the installation source, the earliest release supported by the charm should be returned. ''' - global os_rel + global _os_rel if reset_cache: reset_os_release() - if os_rel: - return os_rel - os_rel = (git_os_codename_install_source(config('openstack-origin-git')) or - get_os_codename_package(package, fatal=False) or - get_os_codename_install_source(config('openstack-origin')) or - base) - return os_rel + if _os_rel: + return _os_rel + _os_rel = ( + git_os_codename_install_source(config('openstack-origin-git')) or + get_os_codename_package(package, fatal=False) or + get_os_codename_install_source(config('openstack-origin')) or + base) + return _os_rel +@deprecate("moved to charmhelpers.fetch.import_key()", "2017-07", log=juju_log) def import_key(keyid): - key = keyid.strip() - if (key.startswith('-----BEGIN PGP PUBLIC KEY BLOCK-----') and - key.endswith('-----END PGP PUBLIC KEY BLOCK-----')): - juju_log("PGP key found (looks like ASCII Armor format)", level=DEBUG) - juju_log("Importing ASCII Armor PGP key", level=DEBUG) - with tempfile.NamedTemporaryFile() as keyfile: - with open(keyfile.name, 'w') as fd: - fd.write(key) - fd.write("\n") + """Import a key, either ASCII armored, or a GPG key id. - cmd = ['apt-key', 'add', keyfile.name] - try: - subprocess.check_call(cmd) - except subprocess.CalledProcessError: - error_out("Error importing PGP key '%s'" % key) - else: - juju_log("PGP key found (looks like Radix64 format)", level=DEBUG) - juju_log("Importing PGP key from keyserver", level=DEBUG) - cmd = ['apt-key', 'adv', '--keyserver', - 'hkp://keyserver.ubuntu.com:80', '--recv-keys', key] - try: - subprocess.check_call(cmd) - except subprocess.CalledProcessError: - error_out("Error importing PGP key '%s'" % key) + @param keyid: the key in ASCII armor format, or a GPG key id. + @raises SystemExit() via sys.exit() on failure. + """ + try: + return fetch_import_key(keyid) + except GPGKeyError as e: + error_out("Could not import key: {}".format(str(e))) -def get_source_and_pgp_key(input): - """Look for a pgp key ID or ascii-armor key in the given input.""" - index = input.strip() - index = input.rfind('|') - if index < 0: - return input, None +def get_source_and_pgp_key(source_and_key): + """Look for a pgp key ID or ascii-armor key in the given input. - key = input[index + 1:].strip('|') - source = input[:index] - return source, key + :param source_and_key: Sting, "source_spec|keyid" where '|keyid' is + optional. + :returns (source_spec, key_id OR None) as a tuple. Returns None for key_id + if there was no '|' in the source_and_key string. + """ + try: + source, key = source_and_key.split('|', 2) + return source, key or None + except ValueError: + return source_and_key, None -def configure_installation_source(rel): - '''Configure apt installation source.''' - if rel == 'distro': - return - elif rel == 'distro-proposed': - ubuntu_rel = lsb_release()['DISTRIB_CODENAME'] - with open('/etc/apt/sources.list.d/juju_deb.list', 'w') as f: - f.write(DISTRO_PROPOSED % ubuntu_rel) - elif rel[:4] == "ppa:": - src, key = get_source_and_pgp_key(rel) - if key: - import_key(key) +@deprecate("use charmhelpers.fetch.add_source() instead.", + "2017-07", log=juju_log) +def configure_installation_source(source_plus_key): + """Configure an installation source. - subprocess.check_call(["add-apt-repository", "-y", src]) - elif rel[:3] == "deb": - src, key = get_source_and_pgp_key(rel) - if key: - import_key(key) + The functionality is provided by charmhelpers.fetch.add_source() + The difference between the two functions is that add_source() signature + requires the key to be passed directly, whereas this function passes an + optional key by appending '|' to the end of the source specificiation + 'source'. - with open('/etc/apt/sources.list.d/juju_deb.list', 'w') as f: - f.write(src) - elif rel[:6] == 'cloud:': - ubuntu_rel = lsb_release()['DISTRIB_CODENAME'] - rel = rel.split(':')[1] - u_rel = rel.split('-')[0] - ca_rel = rel.split('-')[1] + Another difference from add_source() is that the function calls sys.exit(1) + if the configuration fails, whereas add_source() raises + SourceConfigurationError(). Another difference, is that add_source() + silently fails (with a juju_log command) if there is no matching source to + configure, whereas this function fails with a sys.exit(1) - if u_rel != ubuntu_rel: - e = 'Cannot install from Cloud Archive pocket %s on this Ubuntu '\ - 'version (%s)' % (ca_rel, ubuntu_rel) - error_out(e) + :param source: String_plus_key -- see above for details. - if 'staging' in ca_rel: - # staging is just a regular PPA. - os_rel = ca_rel.split('/')[0] - ppa = 'ppa:ubuntu-cloud-archive/%s-staging' % os_rel - cmd = 'add-apt-repository -y %s' % ppa - subprocess.check_call(cmd.split(' ')) - return + Note that the behaviour on error is to log the error to the juju log and + then call sys.exit(1). + """ + # extract the key if there is one, denoted by a '|' in the rel + source, key = get_source_and_pgp_key(source_plus_key) - # map charm config options to actual archive pockets. - pockets = { - 'folsom': 'precise-updates/folsom', - 'folsom/updates': 'precise-updates/folsom', - 'folsom/proposed': 'precise-proposed/folsom', - 'grizzly': 'precise-updates/grizzly', - 'grizzly/updates': 'precise-updates/grizzly', - 'grizzly/proposed': 'precise-proposed/grizzly', - 'havana': 'precise-updates/havana', - 'havana/updates': 'precise-updates/havana', - 'havana/proposed': 'precise-proposed/havana', - 'icehouse': 'precise-updates/icehouse', - 'icehouse/updates': 'precise-updates/icehouse', - 'icehouse/proposed': 'precise-proposed/icehouse', - 'juno': 'trusty-updates/juno', - 'juno/updates': 'trusty-updates/juno', - 'juno/proposed': 'trusty-proposed/juno', - 'kilo': 'trusty-updates/kilo', - 'kilo/updates': 'trusty-updates/kilo', - 'kilo/proposed': 'trusty-proposed/kilo', - 'liberty': 'trusty-updates/liberty', - 'liberty/updates': 'trusty-updates/liberty', - 'liberty/proposed': 'trusty-proposed/liberty', - 'mitaka': 'trusty-updates/mitaka', - 'mitaka/updates': 'trusty-updates/mitaka', - 'mitaka/proposed': 'trusty-proposed/mitaka', - 'newton': 'xenial-updates/newton', - 'newton/updates': 'xenial-updates/newton', - 'newton/proposed': 'xenial-proposed/newton', - 'ocata': 'xenial-updates/ocata', - 'ocata/updates': 'xenial-updates/ocata', - 'ocata/proposed': 'xenial-proposed/ocata', - 'pike': 'xenial-updates/pike', - 'pike/updates': 'xenial-updates/pike', - 'pike/proposed': 'xenial-proposed/pike', - 'queens': 'xenial-updates/queens', - 'queens/updates': 'xenial-updates/queens', - 'queens/proposed': 'xenial-proposed/queens', - } - - try: - pocket = pockets[ca_rel] - except KeyError: - e = 'Invalid Cloud Archive release specified: %s' % rel - error_out(e) - - src = "deb %s %s main" % (CLOUD_ARCHIVE_URL, pocket) - apt_install('ubuntu-cloud-keyring', fatal=True) - - with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as f: - f.write(src) - else: - error_out("Invalid openstack-release specified: %s" % rel) + # handle the ordinary sources via add_source + try: + fetch_add_source(source, key, fail_invalid=True) + except SourceConfigError as se: + error_out(str(se)) def config_value_changed(option): @@ -677,7 +608,6 @@ def openstack_upgrade_available(package): :returns: bool: : Returns True if configured installation source offers a newer version of package. - """ import apt_pkg as apt diff --git a/charmhelpers/core/host.py b/charmhelpers/core/host.py index 88e80a4..b0043cb 100644 --- a/charmhelpers/core/host.py +++ b/charmhelpers/core/host.py @@ -191,6 +191,7 @@ def service_pause(service_name, init_dir="/etc/init", initd_dir="/etc/init.d", upstart_file = os.path.join(init_dir, "{}.conf".format(service_name)) sysv_file = os.path.join(initd_dir, service_name) if init_is_systemd(): + service('disable', service_name) service('mask', service_name) elif os.path.exists(upstart_file): override_path = os.path.join( @@ -225,6 +226,7 @@ def service_resume(service_name, init_dir="/etc/init", sysv_file = os.path.join(initd_dir, service_name) if init_is_systemd(): service('unmask', service_name) + service('enable', service_name) elif os.path.exists(upstart_file): override_path = os.path.join( init_dir, '{}.override'.format(service_name)) diff --git a/charmhelpers/fetch/__init__.py b/charmhelpers/fetch/__init__.py index ec5e0fe..480a627 100644 --- a/charmhelpers/fetch/__init__.py +++ b/charmhelpers/fetch/__init__.py @@ -48,6 +48,13 @@ class AptLockError(Exception): pass +class GPGKeyError(Exception): + """Exception occurs when a GPG key cannot be fetched or used. The message + indicates what the problem is. + """ + pass + + class BaseFetchHandler(object): """Base class for FetchHandler implementations in fetch plugins""" @@ -77,21 +84,22 @@ module = "charmhelpers.fetch.%s" % __platform__ fetch = importlib.import_module(module) filter_installed_packages = fetch.filter_installed_packages -install = fetch.install -upgrade = fetch.upgrade -update = fetch.update -purge = fetch.purge +install = fetch.apt_install +upgrade = fetch.apt_upgrade +update = _fetch_update = fetch.apt_update +purge = fetch.apt_purge add_source = fetch.add_source if __platform__ == "ubuntu": apt_cache = fetch.apt_cache - apt_install = fetch.install - apt_update = fetch.update - apt_upgrade = fetch.upgrade - apt_purge = fetch.purge + apt_install = fetch.apt_install + apt_update = fetch.apt_update + apt_upgrade = fetch.apt_upgrade + apt_purge = fetch.apt_purge apt_mark = fetch.apt_mark apt_hold = fetch.apt_hold apt_unhold = fetch.apt_unhold + import_key = fetch.import_key get_upstream_version = fetch.get_upstream_version elif __platform__ == "centos": yum_search = fetch.yum_search @@ -135,7 +143,7 @@ def configure_sources(update=False, for source, key in zip(sources, keys): add_source(source, key) if update: - fetch.update(fatal=True) + _fetch_update(fatal=True) def install_remote(source, *args, **kwargs): diff --git a/charmhelpers/fetch/centos.py b/charmhelpers/fetch/centos.py index 604bbfb..a91dcff 100644 --- a/charmhelpers/fetch/centos.py +++ b/charmhelpers/fetch/centos.py @@ -132,7 +132,7 @@ def add_source(source, key=None): key_file.write(key) key_file.flush() key_file.seek(0) - subprocess.check_call(['rpm', '--import', key_file]) + subprocess.check_call(['rpm', '--import', key_file.name]) else: subprocess.check_call(['rpm', '--import', key]) diff --git a/charmhelpers/fetch/ubuntu.py b/charmhelpers/fetch/ubuntu.py index 7bc6cc7..57b5fb6 100644 --- a/charmhelpers/fetch/ubuntu.py +++ b/charmhelpers/fetch/ubuntu.py @@ -12,29 +12,47 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections import OrderedDict import os +import platform +import re import six import time import subprocess - from tempfile import NamedTemporaryFile + from charmhelpers.core.host import ( lsb_release ) -from charmhelpers.core.hookenv import log -from charmhelpers.fetch import SourceConfigError +from charmhelpers.core.hookenv import ( + log, + DEBUG, +) +from charmhelpers.fetch import SourceConfigError, GPGKeyError +PROPOSED_POCKET = ( + "# Proposed\n" + "deb http://archive.ubuntu.com/ubuntu {}-proposed main universe " + "multiverse restricted\n") +PROPOSED_PORTS_POCKET = ( + "# Proposed\n" + "deb http://ports.ubuntu.com/ubuntu-ports {}-proposed main universe " + "multiverse restricted\n") +# Only supports 64bit and ppc64 at the moment. +ARCH_TO_PROPOSED_POCKET = { + 'x86_64': PROPOSED_POCKET, + 'ppc64le': PROPOSED_PORTS_POCKET, + 'aarch64': PROPOSED_PORTS_POCKET, +} +CLOUD_ARCHIVE_URL = "http://ubuntu-cloud.archive.canonical.com/ubuntu" +CLOUD_ARCHIVE_KEY_ID = '5EDB1B62EC4926EA' CLOUD_ARCHIVE = """# Ubuntu Cloud Archive deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main """ - -PROPOSED_POCKET = """# Proposed -deb http://archive.ubuntu.com/ubuntu {}-proposed main universe multiverse restricted -""" - CLOUD_ARCHIVE_POCKETS = { # Folsom 'folsom': 'precise-updates/folsom', + 'folsom/updates': 'precise-updates/folsom', 'precise-folsom': 'precise-updates/folsom', 'precise-folsom/updates': 'precise-updates/folsom', 'precise-updates/folsom': 'precise-updates/folsom', @@ -43,6 +61,7 @@ CLOUD_ARCHIVE_POCKETS = { 'precise-proposed/folsom': 'precise-proposed/folsom', # Grizzly 'grizzly': 'precise-updates/grizzly', + 'grizzly/updates': 'precise-updates/grizzly', 'precise-grizzly': 'precise-updates/grizzly', 'precise-grizzly/updates': 'precise-updates/grizzly', 'precise-updates/grizzly': 'precise-updates/grizzly', @@ -51,6 +70,7 @@ CLOUD_ARCHIVE_POCKETS = { 'precise-proposed/grizzly': 'precise-proposed/grizzly', # Havana 'havana': 'precise-updates/havana', + 'havana/updates': 'precise-updates/havana', 'precise-havana': 'precise-updates/havana', 'precise-havana/updates': 'precise-updates/havana', 'precise-updates/havana': 'precise-updates/havana', @@ -59,6 +79,7 @@ CLOUD_ARCHIVE_POCKETS = { 'precise-proposed/havana': 'precise-proposed/havana', # Icehouse 'icehouse': 'precise-updates/icehouse', + 'icehouse/updates': 'precise-updates/icehouse', 'precise-icehouse': 'precise-updates/icehouse', 'precise-icehouse/updates': 'precise-updates/icehouse', 'precise-updates/icehouse': 'precise-updates/icehouse', @@ -67,6 +88,7 @@ CLOUD_ARCHIVE_POCKETS = { 'precise-proposed/icehouse': 'precise-proposed/icehouse', # Juno 'juno': 'trusty-updates/juno', + 'juno/updates': 'trusty-updates/juno', 'trusty-juno': 'trusty-updates/juno', 'trusty-juno/updates': 'trusty-updates/juno', 'trusty-updates/juno': 'trusty-updates/juno', @@ -75,6 +97,7 @@ CLOUD_ARCHIVE_POCKETS = { 'trusty-proposed/juno': 'trusty-proposed/juno', # Kilo 'kilo': 'trusty-updates/kilo', + 'kilo/updates': 'trusty-updates/kilo', 'trusty-kilo': 'trusty-updates/kilo', 'trusty-kilo/updates': 'trusty-updates/kilo', 'trusty-updates/kilo': 'trusty-updates/kilo', @@ -83,6 +106,7 @@ CLOUD_ARCHIVE_POCKETS = { 'trusty-proposed/kilo': 'trusty-proposed/kilo', # Liberty 'liberty': 'trusty-updates/liberty', + 'liberty/updates': 'trusty-updates/liberty', 'trusty-liberty': 'trusty-updates/liberty', 'trusty-liberty/updates': 'trusty-updates/liberty', 'trusty-updates/liberty': 'trusty-updates/liberty', @@ -91,6 +115,7 @@ CLOUD_ARCHIVE_POCKETS = { 'trusty-proposed/liberty': 'trusty-proposed/liberty', # Mitaka 'mitaka': 'trusty-updates/mitaka', + 'mitaka/updates': 'trusty-updates/mitaka', 'trusty-mitaka': 'trusty-updates/mitaka', 'trusty-mitaka/updates': 'trusty-updates/mitaka', 'trusty-updates/mitaka': 'trusty-updates/mitaka', @@ -99,6 +124,7 @@ CLOUD_ARCHIVE_POCKETS = { 'trusty-proposed/mitaka': 'trusty-proposed/mitaka', # Newton 'newton': 'xenial-updates/newton', + 'newton/updates': 'xenial-updates/newton', 'xenial-newton': 'xenial-updates/newton', 'xenial-newton/updates': 'xenial-updates/newton', 'xenial-updates/newton': 'xenial-updates/newton', @@ -107,6 +133,7 @@ CLOUD_ARCHIVE_POCKETS = { 'xenial-proposed/newton': 'xenial-proposed/newton', # Ocata 'ocata': 'xenial-updates/ocata', + 'ocata/updates': 'xenial-updates/ocata', 'xenial-ocata': 'xenial-updates/ocata', 'xenial-ocata/updates': 'xenial-updates/ocata', 'xenial-updates/ocata': 'xenial-updates/ocata', @@ -131,6 +158,7 @@ CLOUD_ARCHIVE_POCKETS = { 'xenial-queens/newton': 'xenial-proposed/queens', } + APT_NO_LOCK = 100 # The return code for "couldn't acquire lock" in APT. CMD_RETRY_DELAY = 10 # Wait 10 seconds between command retries. CMD_RETRY_COUNT = 30 # Retry a failing fatal command X times. @@ -161,7 +189,7 @@ def apt_cache(in_memory=True, progress=None): return apt_pkg.Cache(progress) -def install(packages, options=None, fatal=False): +def apt_install(packages, options=None, fatal=False): """Install one or more packages.""" if options is None: options = ['--option=Dpkg::Options::=--force-confold'] @@ -178,7 +206,7 @@ def install(packages, options=None, fatal=False): _run_apt_command(cmd, fatal) -def upgrade(options=None, fatal=False, dist=False): +def apt_upgrade(options=None, fatal=False, dist=False): """Upgrade all packages.""" if options is None: options = ['--option=Dpkg::Options::=--force-confold'] @@ -193,13 +221,13 @@ def upgrade(options=None, fatal=False, dist=False): _run_apt_command(cmd, fatal) -def update(fatal=False): +def apt_update(fatal=False): """Update local apt cache.""" cmd = ['apt-get', 'update'] _run_apt_command(cmd, fatal) -def purge(packages, fatal=False): +def apt_purge(packages, fatal=False): """Purge one or more packages.""" cmd = ['apt-get', '--assume-yes', 'purge'] if isinstance(packages, six.string_types): @@ -233,7 +261,45 @@ def apt_unhold(packages, fatal=False): return apt_mark(packages, 'unhold', fatal=fatal) -def add_source(source, key=None): +def import_key(keyid): + """Import a key in either ASCII Armor or Radix64 format. + + `keyid` is either the keyid to fetch from a PGP server, or + the key in ASCII armor foramt. + + :param keyid: String of key (or key id). + :raises: GPGKeyError if the key could not be imported + """ + key = keyid.strip() + if (key.startswith('-----BEGIN PGP PUBLIC KEY BLOCK-----') and + key.endswith('-----END PGP PUBLIC KEY BLOCK-----')): + log("PGP key found (looks like ASCII Armor format)", level=DEBUG) + log("Importing ASCII Armor PGP key", level=DEBUG) + with NamedTemporaryFile() as keyfile: + with open(keyfile.name, 'w') as fd: + fd.write(key) + fd.write("\n") + cmd = ['apt-key', 'add', keyfile.name] + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError: + error = "Error importing PGP key '{}'".format(key) + log(error) + raise GPGKeyError(error) + else: + log("PGP key found (looks like Radix64 format)", level=DEBUG) + log("Importing PGP key from keyserver", level=DEBUG) + cmd = ['apt-key', 'adv', '--keyserver', + 'hkp://keyserver.ubuntu.com:80', '--recv-keys', key] + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError: + error = "Error importing PGP key '{}'".format(key) + log(error) + raise GPGKeyError(error) + + +def add_source(source, key=None, fail_invalid=False): """Add a package source to this system. @param source: a URL or sources.list entry, as supported by @@ -249,6 +315,33 @@ def add_source(source, key=None): such as 'cloud:icehouse' 'distro' may be used as a noop + Full list of source specifications supported by the function are: + + 'distro': A NOP; i.e. it has no effect. + 'proposed': the proposed deb spec [2] is wrtten to + /etc/apt/sources.list/proposed + 'distro-proposed': adds -proposed to the debs [2] + 'ppa:': add-apt-repository --yes + 'deb ': add-apt-repository --yes deb + 'http://....': add-apt-repository --yes http://... + 'cloud-archive:': add-apt-repository -yes cloud-archive: + 'cloud:[-staging]': specify a Cloud Archive pocket with + optional staging version. If staging is used then the staging PPA [2] + with be used. If staging is NOT used then the cloud archive [3] will be + added, and the 'ubuntu-cloud-keyring' package will be added for the + current distro. + + Otherwise the source is not recognised and this is logged to the juju log. + However, no error is raised, unless sys_error_on_exit is True. + + [1] deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main + where {} is replaced with the derived pocket name. + [2] deb http://archive.ubuntu.com/ubuntu {}-proposed \ + main universe multiverse restricted + where {} is replaced with the lsb_release codename (e.g. xenial) + [3] deb http://ubuntu-cloud.archive.canonical.com/ubuntu + to /etc/apt/sources.list.d/cloud-archive-list + @param key: A key to be added to the system's APT keyring and used to verify the signatures on packages. Ideally, this should be an ASCII format GPG public key including the block headers. A GPG key @@ -256,51 +349,141 @@ def add_source(source, key=None): available to retrieve the actual public key from a public keyserver placing your Juju environment at risk. ppa and cloud archive keys are securely added automtically, so sould not be provided. + + @param fail_invalid: (boolean) if True, then the function raises a + SourceConfigError is there is no matching installation source. + + @raises SourceConfigError() if for cloud:, the is not a + valid pocket in CLOUD_ARCHIVE_POCKETS """ + _mapping = OrderedDict([ + (r"^distro$", lambda: None), # This is a NOP + (r"^(?:proposed|distro-proposed)$", _add_proposed), + (r"^cloud-archive:(.*)$", _add_apt_repository), + (r"^((?:deb |http:|https:|ppa:).*)$", _add_apt_repository), + (r"^cloud:(.*)-(.*)\/staging$", _add_cloud_staging), + (r"^cloud:(.*)-(.*)$", _add_cloud_distro_check), + (r"^cloud:(.*)$", _add_cloud_pocket), + ]) if source is None: - log('Source is not present. Skipping') - return - - if (source.startswith('ppa:') or - source.startswith('http') or - source.startswith('deb ') or - source.startswith('cloud-archive:')): - cmd = ['add-apt-repository', '--yes', source] - _run_with_retries(cmd) - elif source.startswith('cloud:'): - install(filter_installed_packages(['ubuntu-cloud-keyring']), - fatal=True) - pocket = source.split(':')[-1] - if pocket not in CLOUD_ARCHIVE_POCKETS: - raise SourceConfigError( - 'Unsupported cloud: source option %s' % - pocket) - actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket] - with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt: - apt.write(CLOUD_ARCHIVE.format(actual_pocket)) - elif source == 'proposed': - release = lsb_release()['DISTRIB_CODENAME'] - with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt: - apt.write(PROPOSED_POCKET.format(release)) - elif source == 'distro': - pass + source = '' + for r, fn in six.iteritems(_mapping): + m = re.match(r, source) + if m: + # call the assoicated function with the captured groups + # raises SourceConfigError on error. + fn(*m.groups()) + if key: + try: + import_key(key) + except GPGKeyError as e: + raise SourceConfigError(str(e)) + break else: - log("Unknown source: {!r}".format(source)) + # nothing matched. log an error and maybe sys.exit + err = "Unknown source: {!r}".format(source) + log(err) + if fail_invalid: + raise SourceConfigError(err) - if key: - if '-----BEGIN PGP PUBLIC KEY BLOCK-----' in key: - with NamedTemporaryFile('w+') as key_file: - key_file.write(key) - key_file.flush() - key_file.seek(0) - subprocess.check_call(['apt-key', 'add', '-'], stdin=key_file) - else: - # Note that hkp: is in no way a secure protocol. Using a - # GPG key id is pointless from a security POV unless you - # absolutely trust your network and DNS. - subprocess.check_call(['apt-key', 'adv', '--keyserver', - 'hkp://keyserver.ubuntu.com:80', '--recv', - key]) + +def _add_proposed(): + """Add the PROPOSED_POCKET as /etc/apt/source.list.d/proposed.list + + Uses lsb_release()['DISTRIB_CODENAME'] to determine the correct staza for + the deb line. + + For intel architecutres PROPOSED_POCKET is used for the release, but for + other architectures PROPOSED_PORTS_POCKET is used for the release. + """ + release = lsb_release()['DISTRIB_CODENAME'] + arch = platform.machine() + if arch not in six.iterkeys(ARCH_TO_PROPOSED_POCKET): + raise SourceConfigError("Arch {} not supported for (distro-)proposed" + .format(arch)) + with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt: + apt.write(ARCH_TO_PROPOSED_POCKET[arch].format(release)) + + +def _add_apt_repository(spec): + """Add the spec using add_apt_repository + + :param spec: the parameter to pass to add_apt_repository + """ + _run_with_retries(['add-apt-repository', '--yes', spec]) + + +def _add_cloud_pocket(pocket): + """Add a cloud pocket as /etc/apt/sources.d/cloud-archive.list + + Note that this overwrites the existing file if there is one. + + This function also converts the simple pocket in to the actual pocket using + the CLOUD_ARCHIVE_POCKETS mapping. + + :param pocket: string representing the pocket to add a deb spec for. + :raises: SourceConfigError if the cloud pocket doesn't exist or the + requested release doesn't match the current distro version. + """ + apt_install(filter_installed_packages(['ubuntu-cloud-keyring']), + fatal=True) + if pocket not in CLOUD_ARCHIVE_POCKETS: + raise SourceConfigError( + 'Unsupported cloud: source option %s' % + pocket) + actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket] + with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt: + apt.write(CLOUD_ARCHIVE.format(actual_pocket)) + + +def _add_cloud_staging(cloud_archive_release, openstack_release): + """Add the cloud staging repository which is in + ppa:ubuntu-cloud-archive/-staging + + This function checks that the cloud_archive_release matches the current + codename for the distro that charm is being installed on. + + :param cloud_archive_release: string, codename for the release. + :param openstack_release: String, codename for the openstack release. + :raises: SourceConfigError if the cloud_archive_release doesn't match the + current version of the os. + """ + _verify_is_ubuntu_rel(cloud_archive_release, openstack_release) + ppa = 'ppa:ubuntu-cloud-archive/{}-staging'.format(openstack_release) + cmd = 'add-apt-repository -y {}'.format(ppa) + _run_with_retries(cmd.split(' ')) + + +def _add_cloud_distro_check(cloud_archive_release, openstack_release): + """Add the cloud pocket, but also check the cloud_archive_release against + the current distro, and use the openstack_release as the full lookup. + + This just calls _add_cloud_pocket() with the openstack_release as pocket + to get the correct cloud-archive.list for dpkg to work with. + + :param cloud_archive_release:String, codename for the distro release. + :param openstack_release: String, spec for the release to look up in the + CLOUD_ARCHIVE_POCKETS + :raises: SourceConfigError if this is the wrong distro, or the pocket spec + doesn't exist. + """ + _verify_is_ubuntu_rel(cloud_archive_release, openstack_release) + _add_cloud_pocket("{}-{}".format(cloud_archive_release, openstack_release)) + + +def _verify_is_ubuntu_rel(release, os_release): + """Verify that the release is in the same as the current ubuntu release. + + :param release: String, lowercase for the release. + :param os_release: String, the os_release being asked for + :raises: SourceConfigError if the release is not the same as the ubuntu + release. + """ + ubuntu_rel = lsb_release()['DISTRIB_CODENAME'] + if release != ubuntu_rel: + raise SourceConfigError( + 'Invalid Cloud Archive release specified: {}-{} on this Ubuntu' + 'version ({})'.format(release, os_release, ubuntu_rel)) def _run_with_retries(cmd, max_retries=CMD_RETRY_COUNT, retry_exitcodes=(1,), @@ -316,9 +499,12 @@ def _run_with_retries(cmd, max_retries=CMD_RETRY_COUNT, retry_exitcodes=(1,), :param: cmd_env: dict: Environment variables to add to the command run. """ - env = os.environ.copy() + env = None + kwargs = {} if cmd_env: + env = os.environ.copy() env.update(cmd_env) + kwargs['env'] = env if not retry_message: retry_message = "Failed executing '{}'".format(" ".join(cmd)) @@ -330,7 +516,8 @@ def _run_with_retries(cmd, max_retries=CMD_RETRY_COUNT, retry_exitcodes=(1,), retry_results = (None,) + retry_exitcodes while result in retry_results: try: - result = subprocess.check_call(cmd, env=env) + # result = subprocess.check_call(cmd, env=env) + result = subprocess.check_call(cmd, **kwargs) except subprocess.CalledProcessError as e: retry_count = retry_count + 1 if retry_count > max_retries: @@ -343,6 +530,7 @@ def _run_with_retries(cmd, max_retries=CMD_RETRY_COUNT, retry_exitcodes=(1,), def _run_apt_command(cmd, fatal=False): """Run an apt command with optional retries. + :param: cmd: str: The apt command to run. :param: fatal: bool: Whether the command's output should be checked and retried. """ diff --git a/tests/charmhelpers/__init__.py b/tests/charmhelpers/__init__.py index 4886788..e7aa471 100644 --- a/tests/charmhelpers/__init__.py +++ b/tests/charmhelpers/__init__.py @@ -14,6 +14,11 @@ # Bootstrap charm-helpers, installing its dependencies if necessary using # only standard libraries. +from __future__ import print_function +from __future__ import absolute_import + +import functools +import inspect import subprocess import sys @@ -34,3 +39,59 @@ except ImportError: else: subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml']) import yaml # flake8: noqa + + +# Holds a list of mapping of mangled function names that have been deprecated +# using the @deprecate decorator below. This is so that the warning is only +# printed once for each usage of the function. +__deprecated_functions = {} + + +def deprecate(warning, date=None, log=None): + """Add a deprecation warning the first time the function is used. + The date, which is a string in semi-ISO8660 format indicate the year-month + that the function is officially going to be removed. + + usage: + + @deprecate('use core/fetch/add_source() instead', '2017-04') + def contributed_add_source_thing(...): + ... + + And it then prints to the log ONCE that the function is deprecated. + The reason for passing the logging function (log) is so that hookenv.log + can be used for a charm if needed. + + :param warning: String to indicat where it has moved ot. + :param date: optional sting, in YYYY-MM format to indicate when the + function will definitely (probably) be removed. + :param log: The log function to call to log. If not, logs to stdout + """ + def wrap(f): + + @functools.wraps(f) + def wrapped_f(*args, **kwargs): + try: + module = inspect.getmodule(f) + file = inspect.getsourcefile(f) + lines = inspect.getsourcelines(f) + f_name = "{}-{}-{}..{}-{}".format( + module.__name__, file, lines[0], lines[-1], f.__name__) + except (IOError, TypeError): + # assume it was local, so just use the name of the function + f_name = f.__name__ + if f_name not in __deprecated_functions: + __deprecated_functions[f_name] = True + s = "DEPRECATION WARNING: Function {} is being removed".format( + f.__name__) + if date: + s = "{} on/around {}".format(s, date) + if warning: + s = "{} : {}".format(s, warning) + if log: + log(s) + else: + print(s) + return f(*args, **kwargs) + return wrapped_f + return wrap