summaryrefslogtreecommitdiff
path: root/cloudinit/config
diff options
context:
space:
mode:
Diffstat (limited to 'cloudinit/config')
-rw-r--r--cloudinit/config/cc_keys_to_console.py4
-rw-r--r--cloudinit/config/cc_puppet.py59
-rw-r--r--cloudinit/config/cc_resizefs.py22
-rw-r--r--cloudinit/config/cc_runcmd.py6
-rw-r--r--cloudinit/config/cc_salt_minion.py85
-rw-r--r--cloudinit/config/cc_set_hostname.py41
-rw-r--r--cloudinit/config/cc_snap.py230
-rw-r--r--cloudinit/config/cc_snap_config.py7
-rw-r--r--cloudinit/config/cc_snappy.py8
-rwxr-xr-xcloudinit/config/cc_ssh_authkey_fingerprints.py9
-rw-r--r--cloudinit/config/cc_ubuntu_advantage.py173
-rw-r--r--cloudinit/config/tests/test_snap.py490
-rw-r--r--cloudinit/config/tests/test_ubuntu_advantage.py269
13 files changed, 1352 insertions, 51 deletions
diff --git a/cloudinit/config/cc_keys_to_console.py b/cloudinit/config/cc_keys_to_console.py
index efedd4ae..aff4010e 100644
--- a/cloudinit/config/cc_keys_to_console.py
+++ b/cloudinit/config/cc_keys_to_console.py
@@ -63,9 +63,7 @@ def handle(name, cfg, cloud, log, _args):
["ssh-dss"])
try:
- cmd = [helper_path]
- cmd.append(','.join(fp_blacklist))
- cmd.append(','.join(key_blacklist))
+ cmd = [helper_path, ','.join(fp_blacklist), ','.join(key_blacklist)]
(stdout, _stderr) = util.subp(cmd)
util.multi_log("%s\n" % (stdout.strip()),
stderr=False, console=True)
diff --git a/cloudinit/config/cc_puppet.py b/cloudinit/config/cc_puppet.py
index 28b1d568..4190a20b 100644
--- a/cloudinit/config/cc_puppet.py
+++ b/cloudinit/config/cc_puppet.py
@@ -21,6 +21,13 @@ under ``version``, and defaults to ``none``, which selects the latest version
in the repos. If the ``puppet`` config key exists in the config archive, this
module will attempt to start puppet even if no installation was performed.
+The module also provides keys for configuring the new puppet 4 paths and
+installing the puppet package from the puppetlabs repositories:
+https://docs.puppet.com/puppet/4.2/reference/whered_it_go.html
+The keys are ``package_name``, ``conf_file`` and ``ssl_dir``. If unset, their
+values will default to ones that work with puppet 3.x and with distributions
+that ship modified puppet 4.x that uses the old paths.
+
Puppet configuration can be specified under the ``conf`` key. The
configuration is specified as a dictionary containing high-level ``<section>``
keys and lists of ``<key>=<value>`` pairs within each section. Each section
@@ -44,6 +51,9 @@ in pem format as a multi-line string (using the ``|`` yaml notation).
puppet:
install: <true/false>
version: <version>
+ conf_file: '/etc/puppet/puppet.conf'
+ ssl_dir: '/var/lib/puppet/ssl'
+ package_name: 'puppet'
conf:
agent:
server: "puppetmaster.example.org"
@@ -63,9 +73,17 @@ from cloudinit import helpers
from cloudinit import util
PUPPET_CONF_PATH = '/etc/puppet/puppet.conf'
-PUPPET_SSL_CERT_DIR = '/var/lib/puppet/ssl/certs/'
PUPPET_SSL_DIR = '/var/lib/puppet/ssl'
-PUPPET_SSL_CERT_PATH = '/var/lib/puppet/ssl/certs/ca.pem'
+PUPPET_PACKAGE_NAME = 'puppet'
+
+
+class PuppetConstants(object):
+
+ def __init__(self, puppet_conf_file, puppet_ssl_dir, log):
+ self.conf_path = puppet_conf_file
+ self.ssl_dir = puppet_ssl_dir
+ self.ssl_cert_dir = os.path.join(puppet_ssl_dir, "certs")
+ self.ssl_cert_path = os.path.join(self.ssl_cert_dir, "ca.pem")
def _autostart_puppet(log):
@@ -92,22 +110,29 @@ def handle(name, cfg, cloud, log, _args):
return
puppet_cfg = cfg['puppet']
-
# Start by installing the puppet package if necessary...
install = util.get_cfg_option_bool(puppet_cfg, 'install', True)
version = util.get_cfg_option_str(puppet_cfg, 'version', None)
+ package_name = util.get_cfg_option_str(
+ puppet_cfg, 'package_name', PUPPET_PACKAGE_NAME)
+ conf_file = util.get_cfg_option_str(
+ puppet_cfg, 'conf_file', PUPPET_CONF_PATH)
+ ssl_dir = util.get_cfg_option_str(puppet_cfg, 'ssl_dir', PUPPET_SSL_DIR)
+
+ p_constants = PuppetConstants(conf_file, ssl_dir, log)
if not install and version:
log.warn(("Puppet install set false but version supplied,"
" doing nothing."))
elif install:
log.debug(("Attempting to install puppet %s,"),
version if version else 'latest')
- cloud.distro.install_packages(('puppet', version))
+
+ cloud.distro.install_packages((package_name, version))
# ... and then update the puppet configuration
if 'conf' in puppet_cfg:
# Add all sections from the conf object to puppet.conf
- contents = util.load_file(PUPPET_CONF_PATH)
+ contents = util.load_file(p_constants.conf_path)
# Create object for reading puppet.conf values
puppet_config = helpers.DefaultingConfigParser()
# Read puppet.conf values from original file in order to be able to
@@ -115,20 +140,23 @@ def handle(name, cfg, cloud, log, _args):
# (TODO(harlowja) is this really needed??)
cleaned_lines = [i.lstrip() for i in contents.splitlines()]
cleaned_contents = '\n'.join(cleaned_lines)
- puppet_config.readfp(StringIO(cleaned_contents),
- filename=PUPPET_CONF_PATH)
+ # Move to puppet_config.read_file when dropping py2.7
+ puppet_config.readfp( # pylint: disable=W1505
+ StringIO(cleaned_contents),
+ filename=p_constants.conf_path)
for (cfg_name, cfg) in puppet_cfg['conf'].items():
# Cert configuration is a special case
# Dump the puppet master ca certificate in the correct place
if cfg_name == 'ca_cert':
# Puppet ssl sub-directory isn't created yet
# Create it with the proper permissions and ownership
- util.ensure_dir(PUPPET_SSL_DIR, 0o771)
- util.chownbyname(PUPPET_SSL_DIR, 'puppet', 'root')
- util.ensure_dir(PUPPET_SSL_CERT_DIR)
- util.chownbyname(PUPPET_SSL_CERT_DIR, 'puppet', 'root')
- util.write_file(PUPPET_SSL_CERT_PATH, cfg)
- util.chownbyname(PUPPET_SSL_CERT_PATH, 'puppet', 'root')
+ util.ensure_dir(p_constants.ssl_dir, 0o771)
+ util.chownbyname(p_constants.ssl_dir, 'puppet', 'root')
+ util.ensure_dir(p_constants.ssl_cert_dir)
+
+ util.chownbyname(p_constants.ssl_cert_dir, 'puppet', 'root')
+ util.write_file(p_constants.ssl_cert_path, cfg)
+ util.chownbyname(p_constants.ssl_cert_path, 'puppet', 'root')
else:
# Iterate through the config items, we'll use ConfigParser.set
# to overwrite or create new items as needed
@@ -144,8 +172,9 @@ def handle(name, cfg, cloud, log, _args):
puppet_config.set(cfg_name, o, v)
# We got all our config as wanted we'll rename
# the previous puppet.conf and create our new one
- util.rename(PUPPET_CONF_PATH, "%s.old" % (PUPPET_CONF_PATH))
- util.write_file(PUPPET_CONF_PATH, puppet_config.stringify())
+ util.rename(p_constants.conf_path, "%s.old"
+ % (p_constants.conf_path))
+ util.write_file(p_constants.conf_path, puppet_config.stringify())
# Set it up so it autostarts
_autostart_puppet(log)
diff --git a/cloudinit/config/cc_resizefs.py b/cloudinit/config/cc_resizefs.py
index cec22bb7..c8e1752f 100644
--- a/cloudinit/config/cc_resizefs.py
+++ b/cloudinit/config/cc_resizefs.py
@@ -84,6 +84,10 @@ def _resize_ufs(mount_point, devpth):
return ('growfs', devpth)
+def _resize_zfs(mount_point, devpth):
+ return ('zpool', 'online', '-e', mount_point, devpth)
+
+
def _get_dumpfs_output(mount_point):
dumpfs_res, err = util.subp(['dumpfs', '-m', mount_point])
return dumpfs_res
@@ -148,6 +152,7 @@ RESIZE_FS_PREFIXES_CMDS = [
('ext', _resize_ext),
('xfs', _resize_xfs),
('ufs', _resize_ufs),
+ ('zfs', _resize_zfs),
]
RESIZE_FS_PRECHECK_CMDS = {
@@ -188,6 +193,13 @@ def maybe_get_writable_device_path(devpath, info, log):
log.debug("Not attempting to resize devpath '%s': %s", devpath, info)
return None
+ # FreeBSD zpool can also just use gpt/<label>
+ # with that in mind we can not do an os.stat on "gpt/whatever"
+ # therefore return the devpath already here.
+ if devpath.startswith('gpt/'):
+ log.debug('We have a gpt label - just go ahead')
+ return devpath
+
try:
statret = os.stat(devpath)
except OSError as exc:
@@ -231,6 +243,16 @@ def handle(name, cfg, _cloud, log, args):
(devpth, fs_type, mount_point) = result
+ # if we have a zfs then our device path at this point
+ # is the zfs label. For example: vmzroot/ROOT/freebsd
+ # we will have to get the zpool name out of this
+ # and set the resize_what variable to the zpool
+ # so the _resize_zfs function gets the right attribute.
+ if fs_type == 'zfs':
+ zpool = devpth.split('/')[0]
+ devpth = util.get_device_info_from_zpool(zpool)
+ resize_what = zpool
+
info = "dev=%s mnt_point=%s path=%s" % (devpth, mount_point, resize_what)
log.debug("resize_info: %s" % info)
diff --git a/cloudinit/config/cc_runcmd.py b/cloudinit/config/cc_runcmd.py
index 449872f0..539cbd5d 100644
--- a/cloudinit/config/cc_runcmd.py
+++ b/cloudinit/config/cc_runcmd.py
@@ -39,8 +39,10 @@ schema = {
using ``sh``.
.. note::
- all commands must be proper yaml, so you have to quote any characters
- yaml would eat (':' can be problematic)"""),
+
+ all commands must be proper yaml, so you have to quote any characters
+ yaml would eat (':' can be problematic)
+ """),
'distros': distros,
'examples': [dedent("""\
runcmd:
diff --git a/cloudinit/config/cc_salt_minion.py b/cloudinit/config/cc_salt_minion.py
index 2b388372..d6a21d72 100644
--- a/cloudinit/config/cc_salt_minion.py
+++ b/cloudinit/config/cc_salt_minion.py
@@ -12,7 +12,9 @@ key is present in the config parts, then salt minion will be installed and
started. Configuration for salt minion can be specified in the ``conf`` key
under ``salt_minion``. Any conf values present there will be assigned in
``/etc/salt/minion``. The public and private keys to use for salt minion can be
-specified with ``public_key`` and ``private_key`` respectively.
+specified with ``public_key`` and ``private_key`` respectively. Optionally if
+you have a custom package name, service name or config directory you can
+specify them with ``pkg_name``, ``service_name`` and ``config_dir``.
**Internal name:** ``cc_salt_minion``
@@ -23,8 +25,14 @@ specified with ``public_key`` and ``private_key`` respectively.
**Config keys**::
salt_minion:
+ pkg_name: 'salt-minion'
+ service_name: 'salt-minion'
+ config_dir: '/etc/salt'
conf:
master: salt.example.com
+ grains:
+ role:
+ - web
public_key: |
------BEGIN PUBLIC KEY-------
<key data>
@@ -39,7 +47,34 @@ import os
from cloudinit import util
-# Note: see http://saltstack.org/topics/installation/
+# Note: see https://docs.saltstack.com/en/latest/topics/installation/
+# Note: see https://docs.saltstack.com/en/latest/ref/configuration/
+
+
+class SaltConstants(object):
+ """
+ defines default distribution specific salt variables
+ """
+ def __init__(self, cfg):
+
+ # constants tailored for FreeBSD
+ if util.is_FreeBSD():
+ self.pkg_name = 'py27-salt'
+ self.srv_name = 'salt_minion'
+ self.conf_dir = '/usr/local/etc/salt'
+ # constants for any other OS
+ else:
+ self.pkg_name = 'salt-minion'
+ self.srv_name = 'salt-minion'
+ self.conf_dir = '/etc/salt'
+
+ # if there are constants given in cloud config use those
+ self.pkg_name = util.get_cfg_option_str(cfg, 'pkg_name',
+ self.pkg_name)
+ self.conf_dir = util.get_cfg_option_str(cfg, 'config_dir',
+ self.conf_dir)
+ self.srv_name = util.get_cfg_option_str(cfg, 'service_name',
+ self.srv_name)
def handle(name, cfg, cloud, log, _args):
@@ -49,39 +84,49 @@ def handle(name, cfg, cloud, log, _args):
" no 'salt_minion' key in configuration"), name)
return
- salt_cfg = cfg['salt_minion']
+ s_cfg = cfg['salt_minion']
+ const = SaltConstants(cfg=s_cfg)
# Start by installing the salt package ...
- cloud.distro.install_packages(('salt-minion',))
+ cloud.distro.install_packages(const.pkg_name)
# Ensure we can configure files at the right dir
- config_dir = salt_cfg.get("config_dir", '/etc/salt')
- util.ensure_dir(config_dir)
+ util.ensure_dir(const.conf_dir)
# ... and then update the salt configuration
- if 'conf' in salt_cfg:
- # Add all sections from the conf object to /etc/salt/minion
- minion_config = os.path.join(config_dir, 'minion')
- minion_data = util.yaml_dumps(salt_cfg.get('conf'))
+ if 'conf' in s_cfg:
+ # Add all sections from the conf object to minion config file
+ minion_config = os.path.join(const.conf_dir, 'minion')
+ minion_data = util.yaml_dumps(s_cfg.get('conf'))
util.write_file(minion_config, minion_data)
+ if 'grains' in s_cfg:
+ # add grains to /etc/salt/grains
+ grains_config = os.path.join(const.conf_dir, 'grains')
+ grains_data = util.yaml_dumps(s_cfg.get('grains'))
+ util.write_file(grains_config, grains_data)
+
# ... copy the key pair if specified
- if 'public_key' in salt_cfg and 'private_key' in salt_cfg:
- if os.path.isdir("/etc/salt/pki/minion"):
- pki_dir_default = "/etc/salt/pki/minion"
- else:
- pki_dir_default = "/etc/salt/pki"
+ if 'public_key' in s_cfg and 'private_key' in s_cfg:
+ pki_dir_default = os.path.join(const.conf_dir, "pki/minion")
+ if not os.path.isdir(pki_dir_default):
+ pki_dir_default = os.path.join(const.conf_dir, "pki")
- pki_dir = salt_cfg.get('pki_dir', pki_dir_default)
+ pki_dir = s_cfg.get('pki_dir', pki_dir_default)
with util.umask(0o77):
util.ensure_dir(pki_dir)
pub_name = os.path.join(pki_dir, 'minion.pub')
pem_name = os.path.join(pki_dir, 'minion.pem')
- util.write_file(pub_name, salt_cfg['public_key'])
- util.write_file(pem_name, salt_cfg['private_key'])
+ util.write_file(pub_name, s_cfg['public_key'])
+ util.write_file(pem_name, s_cfg['private_key'])
+
+ # we need to have the salt minion service enabled in rc in order to be
+ # able to start the service. this does only apply on FreeBSD servers.
+ if cloud.distro.osfamily == 'freebsd':
+ cloud.distro.updatercconf('salt_minion_enable', 'YES')
- # restart salt-minion. 'service' will start even if not started. if it
+ # restart salt-minion. 'service' will start even if not started. if it
# was started, it needs to be restarted for config change.
- util.subp(['service', 'salt-minion', 'restart'], capture=False)
+ util.subp(['service', const.srv_name, 'restart'], capture=False)
# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_set_hostname.py b/cloudinit/config/cc_set_hostname.py
index aa3dfe5f..3d2b2da3 100644
--- a/cloudinit/config/cc_set_hostname.py
+++ b/cloudinit/config/cc_set_hostname.py
@@ -32,22 +32,51 @@ will be used.
hostname: <fqdn/hostname>
"""
+import os
+
+
+from cloudinit.atomic_helper import write_json
from cloudinit import util
+class SetHostnameError(Exception):
+ """Raised when the distro runs into an exception when setting hostname.
+
+ This may happen if we attempt to set the hostname early in cloud-init's
+ init-local timeframe as certain services may not be running yet.
+ """
+ pass
+
+
def handle(name, cfg, cloud, log, _args):
if util.get_cfg_option_bool(cfg, "preserve_hostname", False):
log.debug(("Configuration option 'preserve_hostname' is set,"
" not setting the hostname in module %s"), name)
return
-
(hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
+ # Check for previous successful invocation of set-hostname
+
+ # set-hostname artifact file accounts for both hostname and fqdn
+ # deltas. As such, it's format is different than cc_update_hostname's
+ # previous-hostname file which only contains the base hostname.
+ # TODO consolidate previous-hostname and set-hostname artifact files and
+ # distro._read_hostname implementation so we only validate one artifact.
+ prev_fn = os.path.join(cloud.get_cpath('data'), "set-hostname")
+ prev_hostname = {}
+ if os.path.exists(prev_fn):
+ prev_hostname = util.load_json(util.load_file(prev_fn))
+ hostname_changed = (hostname != prev_hostname.get('hostname') or
+ fqdn != prev_hostname.get('fqdn'))
+ if not hostname_changed:
+ log.debug('No hostname changes. Skipping set-hostname')
+ return
+ log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
try:
- log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
cloud.distro.set_hostname(hostname, fqdn)
- except Exception:
- util.logexc(log, "Failed to set the hostname to %s (%s)", fqdn,
- hostname)
- raise
+ except Exception as e:
+ msg = "Failed to set the hostname to %s (%s)" % (fqdn, hostname)
+ util.logexc(log, msg)
+ raise SetHostnameError("%s: %s" % (msg, e))
+ write_json(prev_fn, {'hostname': hostname, 'fqdn': fqdn})
# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_snap.py b/cloudinit/config/cc_snap.py
new file mode 100644
index 00000000..34a53fd4
--- /dev/null
+++ b/cloudinit/config/cc_snap.py
@@ -0,0 +1,230 @@
+# Copyright (C) 2018 Canonical Ltd.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Snap: Install, configure and manage snapd and snap packages."""
+
+import sys
+from textwrap import dedent
+
+from cloudinit import log as logging
+from cloudinit.config.schema import (
+ get_schema_doc, validate_cloudconfig_schema)
+from cloudinit.settings import PER_INSTANCE
+from cloudinit.subp import prepend_base_command
+from cloudinit import util
+
+
+distros = ['ubuntu']
+frequency = PER_INSTANCE
+
+LOG = logging.getLogger(__name__)
+
+schema = {
+ 'id': 'cc_snap',
+ 'name': 'Snap',
+ 'title': 'Install, configure and manage snapd and snap packages',
+ 'description': dedent("""\
+ This module provides a simple configuration namespace in cloud-init to
+ both setup snapd and install snaps.
+
+ .. note::
+ Both ``assertions`` and ``commands`` values can be either a
+ dictionary or a list. If these configs are provided as a
+ dictionary, the keys are only used to order the execution of the
+ assertions or commands and the dictionary is merged with any
+ vendor-data snap configuration provided. If a list is provided by
+ the user instead of a dict, any vendor-data snap configuration is
+ ignored.
+
+ The ``assertions`` configuration option is a dictionary or list of
+ properly-signed snap assertions which will run before any snap
+ ``commands``. They will be added to snapd's assertion database by
+ invoking ``snap ack <aggregate_assertion_file>``.
+
+ Snap ``commands`` is a dictionary or list of individual snap
+ commands to run on the target system. These commands can be used to
+ create snap users, install snaps and provide snap configuration.
+
+ .. note::
+ If 'side-loading' private/unpublished snaps on an instance, it is
+ best to create a snap seed directory and seed.yaml manifest in
+ **/var/lib/snapd/seed/** which snapd automatically installs on
+ startup.
+
+ **Development only**: The ``squashfuse_in_container`` boolean can be
+ set true to install squashfuse package when in a container to enable
+ snap installs. Default is false.
+ """),
+ 'distros': distros,
+ 'examples': [dedent("""\
+ snap:
+ assertions:
+ 00: |
+ signed_assertion_blob_here
+ 02: |
+ signed_assertion_blob_here
+ commands:
+ 00: snap create-user --sudoer --known <snap-user>@mydomain.com
+ 01: snap install canonical-livepatch
+ 02: canonical-livepatch enable <AUTH_TOKEN>
+ """), dedent("""\
+ # LXC-based containers require squashfuse before snaps can be installed
+ snap:
+ commands:
+ 00: apt-get install squashfuse -y
+ 11: snap install emoj
+
+ """), dedent("""\
+ # Convenience: the snap command can be omitted when specifying commands
+ # as a list and 'snap' will automatically be prepended.
+ # The following commands are equivalent:
+ snap:
+ commands:
+ 00: ['install', 'vlc']
+ 01: ['snap', 'install', 'vlc']
+ 02: snap install vlc
+ 03: 'snap install vlc'
+ """)],
+ 'frequency': PER_INSTANCE,
+ 'type': 'object',
+ 'properties': {
+ 'snap': {
+ 'type': 'object',
+ 'properties': {
+ 'assertions': {
+ 'type': ['object', 'array'], # Array of strings or dict
+ 'items': {'type': 'string'},
+ 'additionalItems': False, # Reject items non-string
+ 'minItems': 1,
+ 'minProperties': 1,
+ 'uniqueItems': True
+ },
+ 'commands': {
+ 'type': ['object', 'array'], # Array of strings or dict
+ 'items': {
+ 'oneOf': [
+ {'type': 'array', 'items': {'type': 'string'}},
+ {'type': 'string'}]
+ },
+ 'additionalItems': False, # Reject non-string & non-list
+ 'minItems': 1,
+ 'minProperties': 1,
+ 'uniqueItems': True
+ },
+ 'squashfuse_in_container': {
+ 'type': 'boolean'
+ }
+ },
+ 'additionalProperties': False, # Reject keys not in schema
+ 'required': [],
+ 'minProperties': 1
+ }
+ }
+}
+
+# TODO schema for 'assertions' and 'commands' are too permissive at the moment.
+# Once python-jsonschema supports schema draft 6 add support for arbitrary
+# object keys with 'patternProperties' constraint to validate string values.
+
+__doc__ = get_schema_doc(schema) # Supplement python help()
+
+SNAP_CMD = "snap"
+ASSERTIONS_FILE = "/var/lib/cloud/instance/snapd.assertions"
+
+
+def add_assertions(assertions):
+ """Import list of assertions.
+
+ Import assertions by concatenating each assertion into a
+ string separated by a '\n'. Write this string to a instance file and
+ then invoke `snap ack /path/to/file` and check for errors.
+ If snap exits 0, then all assertions are imported.
+ """
+ if not assertions:
+ return
+ LOG.debug('Importing user-provided snap assertions')
+ if isinstance(assertions, dict):
+ assertions = assertions.values()
+ elif not isinstance(assertions, list):
+ raise TypeError(
+ 'assertion parameter was not a list or dict: {assertions}'.format(
+ assertions=assertions))
+
+ snap_cmd = [SNAP_CMD, 'ack']
+ combined = "\n".join(assertions)
+
+ for asrt in assertions:
+ LOG.debug('Snap acking: %s', asrt.split('\n')[0:2])
+
+ util.write_file(ASSERTIONS_FILE, combined.encode('utf-8'))
+ util.subp(snap_cmd + [ASSERTIONS_FILE], capture=True)
+
+
+def run_commands(commands):
+ """Run the provided commands provided in snap:commands configuration.
+
+ Commands are run individually. Any errors are collected and reported
+ after attempting all commands.
+
+ @param commands: A list or dict containing commands to run. Keys of a
+ dict will be used to order the commands provided as dict values.
+ """
+ if not commands:
+ return
+ LOG.debug('Running user-provided snap commands')
+ if isinstance(commands, dict):
+ # Sort commands based on dictionary key
+ commands = [v for _, v in sorted(commands.items())]
+ elif not isinstance(commands, list):
+ raise TypeError(
+ 'commands parameter was not a list or dict: {commands}'.format(
+ commands=commands))
+
+ fixed_snap_commands = prepend_base_command('snap', commands)
+
+ cmd_failures = []
+ for command in fixed_snap_commands:
+ shell = isinstance(command, str)
+ try:
+ util.subp(command, shell=shell, status_cb=sys.stderr.write)
+ except util.ProcessExecutionError as e:
+ cmd_failures.append(str(e))
+ if cmd_failures:
+ msg = 'Failures running snap commands:\n{cmd_failures}'.format(
+ cmd_failures=cmd_failures)
+ util.logexc(LOG, msg)
+ raise RuntimeError(msg)
+
+
+# RELEASE_BLOCKER: Once LP: #1628289 is released on xenial, drop this function.
+def maybe_install_squashfuse(cloud):
+ """Install squashfuse if we are in a container."""
+ if not util.is_container():
+ return
+ try:
+ cloud.distro.update_package_sources()
+ except Exception as e:
+ util.logexc(LOG, "Package update failed")
+ raise
+ try:
+ cloud.distro.install_packages(['squashfuse'])
+ except Exception as e:
+ util.logexc(LOG, "Failed to install squashfuse")
+ raise
+
+
+def handle(name, cfg, cloud, log, args):
+ cfgin = cfg.get('snap', {})
+ if not cfgin:
+ LOG.debug(("Skipping module named %s,"
+ " no 'snap' key in configuration"), name)
+ return
+
+ validate_cloudconfig_schema(cfg, schema)
+ if util.is_true(cfgin.get('squashfuse_in_container', False)):
+ maybe_install_squashfuse(cloud)
+ add_assertions(cfgin.get('assertions', []))
+ run_commands(cfgin.get('commands', []))
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_snap_config.py b/cloudinit/config/cc_snap_config.py
index e82c0811..afe297ee 100644
--- a/cloudinit/config/cc_snap_config.py
+++ b/cloudinit/config/cc_snap_config.py
@@ -4,11 +4,15 @@
#
# This file is part of cloud-init. See LICENSE file for license information.
+# RELEASE_BLOCKER: Remove this deprecated module in 18.3
"""
Snap Config
-----------
**Summary:** snap_config modules allows configuration of snapd.
+**Deprecated**: Use :ref:`snap` module instead. This module will not exist
+in cloud-init 18.3.
+
This module uses the same ``snappy`` namespace for configuration but
acts only only a subset of the configuration.
@@ -154,6 +158,9 @@ def handle(name, cfg, cloud, log, args):
LOG.debug('No snappy config provided, skipping')
return
+ log.warning(
+ 'DEPRECATION: snap_config module will be dropped in 18.3 release.'
+ ' Use snap module instead')
if not(util.system_is_snappy()):
LOG.debug("%s: system not snappy", name)
return
diff --git a/cloudinit/config/cc_snappy.py b/cloudinit/config/cc_snappy.py
index eecb8178..bab80bbe 100644
--- a/cloudinit/config/cc_snappy.py
+++ b/cloudinit/config/cc_snappy.py
@@ -1,10 +1,14 @@
# This file is part of cloud-init. See LICENSE file for license information.
+# RELEASE_BLOCKER: Remove this deprecated module in 18.3
"""
Snappy
------
**Summary:** snappy modules allows configuration of snappy.
+**Deprecated**: Use :ref:`snap` module instead. This module will not exist
+in cloud-init 18.3.
+
The below example config config would install ``etcd``, and then install
``pkg2.smoser`` with a ``<config-file>`` argument where ``config-file`` has
``config-blob`` inside it. If ``pkgname`` is installed already, then
@@ -271,6 +275,10 @@ def handle(name, cfg, cloud, log, args):
LOG.debug("%s: 'auto' mode, and system not snappy", name)
return
+ log.warning(
+ 'DEPRECATION: snappy module will be dropped in 18.3 release.'
+ ' Use snap module instead')
+
set_snappy_command()
pkg_ops = get_package_ops(packages=mycfg['packages'],
diff --git a/cloudinit/config/cc_ssh_authkey_fingerprints.py b/cloudinit/config/cc_ssh_authkey_fingerprints.py
index 35d8c57f..98b0e665 100755
--- a/cloudinit/config/cc_ssh_authkey_fingerprints.py
+++ b/cloudinit/config/cc_ssh_authkey_fingerprints.py
@@ -77,11 +77,10 @@ def _pprint_key_entries(user, key_fn, key_entries, hash_meth='md5',
tbl = SimpleTable(tbl_fields)
for entry in key_entries:
if _is_printable_key(entry):
- row = []
- row.append(entry.keytype or '-')
- row.append(_gen_fingerprint(entry.base64, hash_meth) or '-')
- row.append(entry.options or '-')
- row.append(entry.comment or '-')
+ row = [entry.keytype or '-',
+ _gen_fingerprint(entry.base64, hash_meth) or '-',
+ entry.options or '-',
+ entry.comment or '-']
tbl.add_row(row)
authtbl_s = tbl.get_string()
authtbl_lines = authtbl_s.splitlines()
diff --git a/cloudinit/config/cc_ubuntu_advantage.py b/cloudinit/config/cc_ubuntu_advantage.py
new file mode 100644
index 00000000..16b1868b
--- /dev/null
+++ b/cloudinit/config/cc_ubuntu_advantage.py
@@ -0,0 +1,173 @@
+# Copyright (C) 2018 Canonical Ltd.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Ubuntu advantage: manage ubuntu-advantage offerings from Canonical."""
+
+import sys
+from textwrap import dedent
+
+from cloudinit import log as logging
+from cloudinit.config.schema import (
+ get_schema_doc, validate_cloudconfig_schema)
+from cloudinit.settings import PER_INSTANCE
+from cloudinit.subp import prepend_base_command
+from cloudinit import util
+
+
+distros = ['ubuntu']
+frequency = PER_INSTANCE
+
+LOG = logging.getLogger(__name__)
+
+schema = {
+ 'id': 'cc_ubuntu_advantage',
+ 'name': 'Ubuntu Advantage',
+ 'title': 'Install, configure and manage ubuntu-advantage offerings',
+ 'description': dedent("""\
+ This module provides configuration options to setup ubuntu-advantage
+ subscriptions.
+
+ .. note::
+ Both ``commands`` value can be either a dictionary or a list. If
+ the configuration provided is a dictionary, the keys are only used
+ to order the execution of the commands and the dictionary is
+ merged with any vendor-data ubuntu-advantage configuration
+ provided. If a ``commands`` is provided as a list, any vendor-data
+ ubuntu-advantage ``commands`` are ignored.
+
+ Ubuntu-advantage ``commands`` is a dictionary or list of
+ ubuntu-advantage commands to run on the deployed machine.
+ These commands can be used to enable or disable subscriptions to
+ various ubuntu-advantage products. See 'man ubuntu-advantage' for more
+ information on supported subcommands.
+
+ .. note::
+ Each command item can be a string or list. If the item is a list,
+ 'ubuntu-advantage' can be omitted and it will automatically be
+ inserted as part of the command.
+ """),
+ 'distros': distros,
+ 'examples': [dedent("""\
+ # Enable Extended Security Maintenance using your service auth token
+ ubuntu-advantage:
+ commands:
+ 00: ubuntu-advantage enable-esm <token>
+ """), dedent("""\
+ # Enable livepatch by providing your livepatch token
+ ubuntu-advantage:
+ commands:
+ 00: ubuntu-advantage enable-livepatch <livepatch-token>
+
+ """), dedent("""\
+ # Convenience: the ubuntu-advantage command can be omitted when
+ # specifying commands as a list and 'ubuntu-advantage' will
+ # automatically be prepended.
+ # The following commands are equivalent
+ ubuntu-advantage:
+ commands:
+ 00: ['enable-livepatch', 'my-token']
+ 01: ['ubuntu-advantage', 'enable-livepatch', 'my-token']
+ 02: ubuntu-advantage enable-livepatch my-token
+ 03: 'ubuntu-advantage enable-livepatch my-token'
+ """)],
+ 'frequency': PER_INSTANCE,
+ 'type': 'object',
+ 'properties': {
+ 'ubuntu-advantage': {
+ 'type': 'object',
+ 'properties': {
+ 'commands': {
+ 'type': ['object', 'array'], # Array of strings or dict
+ 'items': {
+ 'oneOf': [
+ {'type': 'array', 'items': {'type': 'string'}},
+ {'type': 'string'}]
+ },
+ 'additionalItems': False, # Reject non-string & non-list
+ 'minItems': 1,
+ 'minProperties': 1,
+ 'uniqueItems': True
+ }
+ },
+ 'additionalProperties': False, # Reject keys not in schema
+ 'required': ['commands']
+ }
+ }
+}
+
+# TODO schema for 'assertions' and 'commands' are too permissive at the moment.
+# Once python-jsonschema supports schema draft 6 add support for arbitrary
+# object keys with 'patternProperties' constraint to validate string values.
+
+__doc__ = get_schema_doc(schema) # Supplement python help()
+
+UA_CMD = "ubuntu-advantage"
+
+
+def run_commands(commands):
+ """Run the commands provided in ubuntu-advantage:commands config.
+
+ Commands are run individually. Any errors are collected and reported
+ after attempting all commands.
+
+ @param commands: A list or dict containing commands to run. Keys of a
+ dict will be used to order the commands provided as dict values.
+ """
+ if not commands:
+ return
+ LOG.debug('Running user-provided ubuntu-advantage commands')
+ if isinstance(commands, dict):
+ # Sort commands based on dictionary key
+ commands = [v for _, v in sorted(commands.items())]
+ elif not isinstance(commands, list):
+ raise TypeError(
+ 'commands parameter was not a list or dict: {commands}'.format(
+ commands=commands))
+
+ fixed_ua_commands = prepend_base_command('ubuntu-advantage', commands)
+
+ cmd_failures = []
+ for command in fixed_ua_commands:
+ shell = isinstance(command, str)
+ try:
+ util.subp(command, shell=shell, status_cb=sys.stderr.write)
+ except util.ProcessExecutionError as e:
+ cmd_failures.append(str(e))
+ if cmd_failures:
+ msg = (
+ 'Failures running ubuntu-advantage commands:\n'
+ '{cmd_failures}'.format(
+ cmd_failures=cmd_failures))
+ util.logexc(LOG, msg)
+ raise RuntimeError(msg)
+
+
+def maybe_install_ua_tools(cloud):
+ """Install ubuntu-advantage-tools if not present."""
+ if util.which('ubuntu-advantage'):
+ return
+ try:
+ cloud.distro.update_package_sources()
+ except Exception as e:
+ util.logexc(LOG, "Package update failed")
+ raise
+ try:
+ cloud.distro.install_packages(['ubuntu-advantage-tools'])
+ except Exception as e:
+ util.logexc(LOG, "Failed to install ubuntu-advantage-tools")
+ raise
+
+
+def handle(name, cfg, cloud, log, args):
+ cfgin = cfg.get('ubuntu-advantage')
+ if cfgin is None:
+ LOG.debug(("Skipping module named %s,"
+ " no 'ubuntu-advantage' key in configuration"), name)
+ return
+
+ validate_cloudconfig_schema(cfg, schema)
+ maybe_install_ua_tools(cloud)
+ run_commands(cfgin.get('commands', []))
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_snap.py b/cloudinit/config/tests/test_snap.py
new file mode 100644
index 00000000..c5b4a9de
--- /dev/null
+++ b/cloudinit/config/tests/test_snap.py
@@ -0,0 +1,490 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import re
+from six import StringIO
+
+from cloudinit.config.cc_snap import (
+ ASSERTIONS_FILE, add_assertions, handle, maybe_install_squashfuse,
+ run_commands, schema)
+from cloudinit.config.schema import validate_cloudconfig_schema
+from cloudinit import util
+from cloudinit.tests.helpers import (
+ CiTestCase, mock, wrap_and_call, skipUnlessJsonSchema)
+
+
+SYSTEM_USER_ASSERTION = """\
+type: system-user
+authority-id: LqvZQdfyfGlYvtep4W6Oj6pFXP9t1Ksp
+brand-id: LqvZQdfyfGlYvtep4W6Oj6pFXP9t1Ksp
+email: foo@bar.com
+password: $6$E5YiAuMIPAwX58jG$miomhVNui/vf7f/3ctB/f0RWSKFxG0YXzrJ9rtJ1ikvzt
+series:
+- 16
+since: 2016-09-10T16:34:00+03:00
+until: 2017-11-10T16:34:00+03:00
+username: baz
+sign-key-sha3-384: RuVvnp4n52GilycjfbbTCI3_L8Y6QlIE75wxMc0KzGV3AUQqVd9GuXoj
+
+AcLBXAQAAQoABgUCV/UU1wAKCRBKnlMoJQLkZVeLD/9/+hIeVywtzsDA3oxl+P+u9D13y9s6svP
+Jd6Wnf4FTw6sq1GjBE4ZA7lrwSaRCUJ9Vcsvf2q9OGPY7mOb2TBxaDe0PbUMjrSrqllSSQwhpNI
+zG+NxkkKuxsUmLzFa+k9m6cyojNbw5LFhQZBQCGlr3JYqC0tIREq/UsZxj+90TUC87lDJwkU8GF
+s4CR+rejZj4itIcDcVxCSnJH6hv6j2JrJskJmvObqTnoOlcab+JXdamXqbldSP3UIhWoyVjqzkj
++to7mXgx+cCUA9+ngNCcfUG+1huGGTWXPCYkZ78HvErcRlIdeo4d3xwtz1cl/w3vYnq9og1XwsP
+Yfetr3boig2qs1Y+j/LpsfYBYncgWjeDfAB9ZZaqQz/oc8n87tIPZDJHrusTlBfop8CqcM4xsKS
+d+wnEY8e/F24mdSOYmS1vQCIDiRU3MKb6x138Ud6oHXFlRBbBJqMMctPqWDunWzb5QJ7YR0I39q
+BrnEqv5NE0G7w6HOJ1LSPG5Hae3P4T2ea+ATgkb03RPr3KnXnzXg4TtBbW1nytdlgoNc/BafE1H
+f3NThcq9gwX4xWZ2PAWnqVPYdDMyCtzW3Ck+o6sIzx+dh4gDLPHIi/6TPe/pUuMop9CBpWwez7V
+v1z+1+URx6Xlq3Jq18y5pZ6fY3IDJ6km2nQPMzcm4Q=="""
+
+ACCOUNT_ASSERTION = """\
+type: account-key
+authority-id: canonical
+revision: 2
+public-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0
+account-id: canonical
+name: store
+since: 2016-04-01T00:00:00.0Z
+body-length: 717
+sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswH
+
+AcbBTQRWhcGAARAA0KKYYQWuHOrsFVi4p4l7ZzSvX7kLgJFFeFgOkzdWKBTHEnsMKjl5mefFe9j
+qe8NlmJdfY7BenP7XeBtwKp700H/t9lLrZbpTNAPHXYxEWFJp5bPqIcJYBZ+29oLVLN1Tc5X482
+vCiDqL8+pPYqBrK2fNlyPlNNSum9wI70rDDL4r6FVvr+osTnGejibdV8JphWX+lrSQDnRSdM8KJ
+UM43vTgLGTi9W54oRhsA2OFexRfRksTrnqGoonCjqX5wO3OFSaMDzMsO2MJ/hPfLgDqw53qjzuK
+Iec9OL3k5basvu2cj5u9tKwVFDsCKK2GbKUsWWpx2KTpOifmhmiAbzkTHbH9KaoMS7p0kJwhTQG
+o9aJ9VMTWHJc/NCBx7eu451u6d46sBPCXS/OMUh2766fQmoRtO1OwCTxsRKG2kkjbMn54UdFULl
+VfzvyghMNRKIezsEkmM8wueTqGUGZWa6CEZqZKwhe/PROxOPYzqtDH18XZknbU1n5lNb7vNfem9
+2ai+3+JyFnW9UhfvpVF7gzAgdyCqNli4C6BIN43uwoS8HkykocZS/+Gv52aUQ/NZ8BKOHLw+7an
+Q0o8W9ltSLZbEMxFIPSN0stiZlkXAp6DLyvh1Y4wXSynDjUondTpej2fSvSlCz/W5v5V7qA4nIc
+vUvV7RjVzv17ut0AEQEAAQ==
+
+AcLDXAQAAQoABgUCV83k9QAKCRDUpVvql9g3IBT8IACKZ7XpiBZ3W4lqbPssY6On81WmxQLtvsM
+WTp6zZpl/wWOSt2vMNUk9pvcmrNq1jG9CuhDfWFLGXEjcrrmVkN3YuCOajMSPFCGrxsIBLSRt/b
+nrKykdLAAzMfG8rP1d82bjFFiIieE+urQ0Kcv09Jtdvavq3JT1Tek5mFyyfhHNlQEKOzWqmRWiL
+3c3VOZUs1ZD8TSlnuq/x+5T0X0YtOyGjSlVxk7UybbyMNd6MZfNaMpIG4x+mxD3KHFtBAC7O6kL
+eX3i6j5nCY5UABfA3DZEAkWP4zlmdBEOvZ9t293NaDdOpzsUHRkoi0Zez/9BHQ/kwx/uNc2WqrY
+inCmu16JGNeXqsyinnLl7Ghn2RwhvDMlLxF6RTx8xdx1yk6p3PBTwhZMUvuZGjUtN/AG8BmVJQ1
+rsGSRkkSywvnhVJRB2sudnrMBmNS2goJbzSbmJnOlBrd2WsV0T9SgNMWZBiov3LvU4o2SmAb6b+
+rYwh8H5QHcuuYJuxDjFhPswIp6Wes5T6hUicf3SWtObcDS4HSkVS4ImBjjX9YgCuFy7QdnooOWE
+aPvkRw3XCVeYq0K6w9GRsk1YFErD4XmXXZjDYY650MX9v42Sz5MmphHV8jdIY5ssbadwFSe2rCQ
+6UX08zy7RsIb19hTndE6ncvSNDChUR9eEnCm73eYaWTWTnq1cxdVP/s52r8uss++OYOkPWqh5nO
+haRn7INjH/yZX4qXjNXlTjo0PnHH0q08vNKDwLhxS+D9du+70FeacXFyLIbcWllSbJ7DmbumGpF
+yYbtj3FDDPzachFQdIG3lSt+cSUGeyfSs6wVtc3cIPka/2Urx7RprfmoWSI6+a5NcLdj0u2z8O9
+HxeIgxDpg/3gT8ZIuFKePMcLDM19Fh/p0ysCsX+84B9chNWtsMSmIaE57V+959MVtsLu7SLb9gi
+skrju0pQCwsu2wHMLTNd1f3PTHmrr49hxetTus07HSQUApMtAGKzQilF5zqFjbyaTd4xgQbd+PK
+CjFyzQTDOcUhXpuUGt/IzlqiFfsCsmbj2K4KdSNYMlqIgZ3Azu8KvZLIhsyN7v5vNIZSPfEbjde
+ClU9r0VRiJmtYBUjcSghD9LWn+yRLwOxhfQVjm0cBwIt5R/yPF/qC76yIVuWUtM5Y2/zJR1J8OF
+qWchvlImHtvDzS9FQeLyzJAOjvZ2CnWp2gILgUz0WQdOk1Dq8ax7KS9BQ42zxw9EZAEPw3PEFqR
+IQsRTONp+iVS8YxSmoYZjDlCgRMWUmawez/Fv5b9Fb/XkO5Eq4e+KfrpUujXItaipb+tV8h5v3t
+oG3Ie3WOHrVjCLXIdYslpL1O4nadqR6Xv58pHj6k"""
+
+
+class FakeCloud(object):
+ def __init__(self, distro):
+ self.distro = distro
+
+
+class TestAddAssertions(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestAddAssertions, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_add_assertions_on_empty_list(self, m_subp):
+ """When provided with an empty list, add_assertions does nothing."""
+ add_assertions([])
+ self.assertEqual('', self.logs.getvalue())
+ m_subp.assert_not_called()
+
+ def test_add_assertions_on_non_list_or_dict(self):
+ """When provided an invalid type, add_assertions raises an error."""
+ with self.assertRaises(TypeError) as context_manager:
+ add_assertions(assertions="I'm Not Valid")
+ self.assertEqual(
+ "assertion parameter was not a list or dict: I'm Not Valid",
+ str(context_manager.exception))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_add_assertions_adds_assertions_as_list(self, m_subp):
+ """When provided with a list, add_assertions adds all assertions."""
+ self.assertEqual(
+ ASSERTIONS_FILE, '/var/lib/cloud/instance/snapd.assertions')
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ assertions = [SYSTEM_USER_ASSERTION, ACCOUNT_ASSERTION]
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ add_assertions, assertions)
+ self.assertIn(
+ 'Importing user-provided snap assertions', self.logs.getvalue())
+ self.assertIn(
+ 'sertions', self.logs.getvalue())
+ self.assertEqual(
+ [mock.call(['snap', 'ack', assert_file], capture=True)],
+ m_subp.call_args_list)
+ compare_file = self.tmp_path('comparison', dir=self.tmp)
+ util.write_file(compare_file, '\n'.join(assertions).encode('utf-8'))
+ self.assertEqual(
+ util.load_file(compare_file), util.load_file(assert_file))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_add_assertions_adds_assertions_as_dict(self, m_subp):
+ """When provided with a dict, add_assertions adds all assertions."""
+ self.assertEqual(
+ ASSERTIONS_FILE, '/var/lib/cloud/instance/snapd.assertions')
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ assertions = {'00': SYSTEM_USER_ASSERTION, '01': ACCOUNT_ASSERTION}
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ add_assertions, assertions)
+ self.assertIn(
+ 'Importing user-provided snap assertions', self.logs.getvalue())
+ self.assertIn(
+ "DEBUG: Snap acking: ['type: system-user', 'authority-id: Lqv",
+ self.logs.getvalue())
+ self.assertIn(
+ "DEBUG: Snap acking: ['type: account-key', 'authority-id: canonic",
+ self.logs.getvalue())
+ self.assertEqual(
+ [mock.call(['snap', 'ack', assert_file], capture=True)],
+ m_subp.call_args_list)
+ compare_file = self.tmp_path('comparison', dir=self.tmp)
+ combined = '\n'.join(assertions.values())
+ util.write_file(compare_file, combined.encode('utf-8'))
+ self.assertEqual(
+ util.load_file(compare_file), util.load_file(assert_file))
+
+
+class TestRunCommands(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestRunCommands, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_run_commands_on_empty_list(self, m_subp):
+ """When provided with an empty list, run_commands does nothing."""
+ run_commands([])
+ self.assertEqual('', self.logs.getvalue())
+ m_subp.assert_not_called()
+
+ def test_run_commands_on_non_list_or_dict(self):
+ """When provided an invalid type, run_commands raises an error."""
+ with self.assertRaises(TypeError) as context_manager:
+ run_commands(commands="I'm Not Valid")
+ self.assertEqual(
+ "commands parameter was not a list or dict: I'm Not Valid",
+ str(context_manager.exception))
+
+ def test_run_command_logs_commands_and_exit_codes_to_stderr(self):
+ """All exit codes are logged to stderr."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'bogus command'
+ cmd3 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2, cmd3]
+
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO) as m_stderr:
+ with self.assertRaises(RuntimeError) as context_manager:
+ run_commands(commands=commands)
+
+ self.assertIsNotNone(
+ re.search(r'bogus: (command )?not found',
+ str(context_manager.exception)),
+ msg='Expected bogus command not found')
+ expected_stderr_log = '\n'.join([
+ 'Begin run command: {cmd}'.format(cmd=cmd1),
+ 'End run command: exit(0)',
+ 'Begin run command: {cmd}'.format(cmd=cmd2),
+ 'ERROR: End run command: exit(127)',
+ 'Begin run command: {cmd}'.format(cmd=cmd3),
+ 'End run command: exit(0)\n'])
+ self.assertEqual(expected_stderr_log, m_stderr.getvalue())
+
+ def test_run_command_as_lists(self):
+ """When commands are specified as a list, run them in order."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2]
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ self.assertIn(
+ 'DEBUG: Running user-provided snap commands',
+ self.logs.getvalue())
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+ self.assertIn(
+ 'WARNING: Non-snap commands in snap config:', self.logs.getvalue())
+
+ def test_run_command_dict_sorted_as_command_script(self):
+ """When commands are a dict, sort them and run."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = {'02': cmd1, '01': cmd2}
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ expected_messages = [
+ 'DEBUG: Running user-provided snap commands']
+ for message in expected_messages:
+ self.assertIn(message, self.logs.getvalue())
+ self.assertEqual('MOM\nHI\n', util.load_file(outfile))
+
+
+@skipUnlessJsonSchema()
+class TestSchema(CiTestCase):
+
+ with_logs = True
+
+ def test_schema_warns_on_snap_not_as_dict(self):
+ """If the snap configuration is not a dict, emit a warning."""
+ validate_cloudconfig_schema({'snap': 'wrong type'}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap: 'wrong type' is not of type"
+ " 'object'\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_schema_disallows_unknown_keys(self, _):
+ """Unknown keys in the snap configuration emit warnings."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': ['ls'], 'invalid-key': ''}}, schema)
+ self.assertIn(
+ 'WARNING: Invalid config:\nsnap: Additional properties are not'
+ " allowed ('invalid-key' was unexpected)",
+ self.logs.getvalue())
+
+ def test_warn_schema_requires_either_commands_or_assertions(self):
+ """Warn when snap configuration lacks both commands and assertions."""
+ validate_cloudconfig_schema(
+ {'snap': {}}, schema)
+ self.assertIn(
+ 'WARNING: Invalid config:\nsnap: {} does not have enough'
+ ' properties',
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_warn_schema_commands_is_not_list_or_dict(self, _):
+ """Warn when snap:commands config is not a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': 'broken'}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.commands: 'broken' is not of type"
+ " 'object', 'array'\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_warn_schema_when_commands_is_empty(self, _):
+ """Emit warnings when snap:commands is an empty list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': []}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'commands': {}}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.commands: [] is too short\n"
+ "WARNING: Invalid config:\nsnap.commands: {} does not have enough"
+ " properties\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_schema_when_commands_are_list_or_dict(self, _):
+ """No warnings when snap:commands are either a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': ['valid']}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'commands': {'01': 'also valid'}}}, schema)
+ self.assertEqual('', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ def test_warn_schema_assertions_is_not_list_or_dict(self, _):
+ """Warn when snap:assertions config is not a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': 'broken'}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.assertions: 'broken' is not of"
+ " type 'object', 'array'\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ def test_warn_schema_when_assertions_is_empty(self, _):
+ """Emit warnings when snap:assertions is an empty list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': []}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': {}}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.assertions: [] is too short\n"
+ "WARNING: Invalid config:\nsnap.assertions: {} does not have"
+ " enough properties\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ def test_schema_when_assertions_are_list_or_dict(self, _):
+ """No warnings when snap:assertions are a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': ['valid']}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': {'01': 'also valid'}}}, schema)
+ self.assertEqual('', self.logs.getvalue())
+
+
+class TestHandle(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestHandle, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ @mock.patch('cloudinit.config.cc_snap.validate_cloudconfig_schema')
+ def test_handle_no_config(self, m_schema, m_add, m_run):
+ """When no snap-related configuration is provided, nothing happens."""
+ cfg = {}
+ handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertIn(
+ "DEBUG: Skipping module named snap, no 'snap' key in config",
+ self.logs.getvalue())
+ m_schema.assert_not_called()
+ m_add.assert_not_called()
+ m_run.assert_not_called()
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ @mock.patch('cloudinit.config.cc_snap.maybe_install_squashfuse')
+ def test_handle_skips_squashfuse_when_unconfigured(self, m_squash, m_add,
+ m_run):
+ """When squashfuse_in_container is unset, don't attempt to install."""
+ handle(
+ 'snap', cfg={'snap': {}}, cloud=None, log=self.logger, args=None)
+ handle(
+ 'snap', cfg={'snap': {'squashfuse_in_container': None}},
+ cloud=None, log=self.logger, args=None)
+ handle(
+ 'snap', cfg={'snap': {'squashfuse_in_container': False}},
+ cloud=None, log=self.logger, args=None)
+ self.assertEqual([], m_squash.call_args_list) # No calls
+ # snap configuration missing assertions and commands will default to []
+ self.assertIn(mock.call([]), m_add.call_args_list)
+ self.assertIn(mock.call([]), m_run.call_args_list)
+
+ @mock.patch('cloudinit.config.cc_snap.maybe_install_squashfuse')
+ def test_handle_tries_to_install_squashfuse(self, m_squash):
+ """If squashfuse_in_container is True, try installing squashfuse."""
+ cfg = {'snap': {'squashfuse_in_container': True}}
+ mycloud = FakeCloud(None)
+ handle('snap', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
+ self.assertEqual(
+ [mock.call(mycloud)], m_squash.call_args_list)
+
+ def test_handle_runs_commands_provided(self):
+ """If commands are specified as a list, run them."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cfg = {
+ 'snap': {'commands': ['echo "HI" >> %s' % outfile,
+ 'echo "MOM" >> %s' % outfile]}}
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO):
+ handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_handle_adds_assertions(self, m_subp):
+ """Any configured snap assertions are provided to add_assertions."""
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ compare_file = self.tmp_path('comparison', dir=self.tmp)
+ cfg = {
+ 'snap': {'assertions': [SYSTEM_USER_ASSERTION, ACCOUNT_ASSERTION]}}
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ handle, 'snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ content = '\n'.join(cfg['snap']['assertions'])
+ util.write_file(compare_file, content.encode('utf-8'))
+ self.assertEqual(
+ util.load_file(compare_file), util.load_file(assert_file))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ @skipUnlessJsonSchema()
+ def test_handle_validates_schema(self, m_subp):
+ """Any provided configuration is runs validate_cloudconfig_schema."""
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ cfg = {'snap': {'invalid': ''}} # Generates schema warning
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ handle, 'snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap: Additional properties are not"
+ " allowed ('invalid' was unexpected)\n",
+ self.logs.getvalue())
+
+
+class TestMaybeInstallSquashFuse(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestMaybeInstallSquashFuse, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_skips_non_containers(self, m_container):
+ """maybe_install_squashfuse does nothing when not on a container."""
+ m_container.return_value = False
+ maybe_install_squashfuse(cloud=FakeCloud(None))
+ self.assertEqual([mock.call()], m_container.call_args_list)
+ self.assertEqual('', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_raises_install_errors(self, m_container):
+ """maybe_install_squashfuse logs and raises package install errors."""
+ m_container.return_value = True
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_squashfuse(cloud=FakeCloud(distro))
+ self.assertEqual('Some apt error', str(context_manager.exception))
+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_raises_update_errors(self, m_container):
+ """maybe_install_squashfuse logs and raises package update errors."""
+ m_container.return_value = True
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_squashfuse(cloud=FakeCloud(distro))
+ self.assertEqual('Some apt error', str(context_manager.exception))
+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_happy_path(self, m_container):
+ """maybe_install_squashfuse logs and raises package install errors."""
+ m_container.return_value = True
+ distro = mock.MagicMock() # No errors raised
+ maybe_install_squashfuse(cloud=FakeCloud(distro))
+ self.assertEqual(
+ [mock.call()], distro.update_package_sources.call_args_list)
+ self.assertEqual(
+ [mock.call(['squashfuse'])],
+ distro.install_packages.call_args_list)
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_ubuntu_advantage.py b/cloudinit/config/tests/test_ubuntu_advantage.py
new file mode 100644
index 00000000..f2a59faf
--- /dev/null
+++ b/cloudinit/config/tests/test_ubuntu_advantage.py
@@ -0,0 +1,269 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import re
+from six import StringIO
+
+from cloudinit.config.cc_ubuntu_advantage import (
+ handle, maybe_install_ua_tools, run_commands, schema)
+from cloudinit.config.schema import validate_cloudconfig_schema
+from cloudinit import util
+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
+
+
+# Module path used in mocks
+MPATH = 'cloudinit.config.cc_ubuntu_advantage'
+
+
+class FakeCloud(object):
+ def __init__(self, distro):
+ self.distro = distro
+
+
+class TestRunCommands(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestRunCommands, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('%s.util.subp' % MPATH)
+ def test_run_commands_on_empty_list(self, m_subp):
+ """When provided with an empty list, run_commands does nothing."""
+ run_commands([])
+ self.assertEqual('', self.logs.getvalue())
+ m_subp.assert_not_called()
+
+ def test_run_commands_on_non_list_or_dict(self):
+ """When provided an invalid type, run_commands raises an error."""
+ with self.assertRaises(TypeError) as context_manager:
+ run_commands(commands="I'm Not Valid")
+ self.assertEqual(
+ "commands parameter was not a list or dict: I'm Not Valid",
+ str(context_manager.exception))
+
+ def test_run_command_logs_commands_and_exit_codes_to_stderr(self):
+ """All exit codes are logged to stderr."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'bogus command'
+ cmd3 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2, cmd3]
+
+ mock_path = '%s.sys.stderr' % MPATH
+ with mock.patch(mock_path, new_callable=StringIO) as m_stderr:
+ with self.assertRaises(RuntimeError) as context_manager:
+ run_commands(commands=commands)
+
+ self.assertIsNotNone(
+ re.search(r'bogus: (command )?not found',
+ str(context_manager.exception)),
+ msg='Expected bogus command not found')
+ expected_stderr_log = '\n'.join([
+ 'Begin run command: {cmd}'.format(cmd=cmd1),
+ 'End run command: exit(0)',
+ 'Begin run command: {cmd}'.format(cmd=cmd2),
+ 'ERROR: End run command: exit(127)',
+ 'Begin run command: {cmd}'.format(cmd=cmd3),
+ 'End run command: exit(0)\n'])
+ self.assertEqual(expected_stderr_log, m_stderr.getvalue())
+
+ def test_run_command_as_lists(self):
+ """When commands are specified as a list, run them in order."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2]
+ with mock.patch('%s.sys.stderr' % MPATH, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ self.assertIn(
+ 'DEBUG: Running user-provided ubuntu-advantage commands',
+ self.logs.getvalue())
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+ self.assertIn(
+ 'WARNING: Non-ubuntu-advantage commands in ubuntu-advantage'
+ ' config:',
+ self.logs.getvalue())
+
+ def test_run_command_dict_sorted_as_command_script(self):
+ """When commands are a dict, sort them and run."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = {'02': cmd1, '01': cmd2}
+ with mock.patch('%s.sys.stderr' % MPATH, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ expected_messages = [
+ 'DEBUG: Running user-provided ubuntu-advantage commands']
+ for message in expected_messages:
+ self.assertIn(message, self.logs.getvalue())
+ self.assertEqual('MOM\nHI\n', util.load_file(outfile))
+
+
+@skipUnlessJsonSchema()
+class TestSchema(CiTestCase):
+
+ with_logs = True
+
+ def test_schema_warns_on_ubuntu_advantage_not_as_dict(self):
+ """If ubuntu-advantage configuration is not a dict, emit a warning."""
+ validate_cloudconfig_schema({'ubuntu-advantage': 'wrong type'}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage: 'wrong type' is not"
+ " of type 'object'\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_schema_disallows_unknown_keys(self, _):
+ """Unknown keys in ubuntu-advantage configuration emit warnings."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': ['ls'], 'invalid-key': ''}},
+ schema)
+ self.assertIn(
+ 'WARNING: Invalid config:\nubuntu-advantage: Additional properties'
+ " are not allowed ('invalid-key' was unexpected)",
+ self.logs.getvalue())
+
+ def test_warn_schema_requires_commands(self):
+ """Warn when ubuntu-advantage configuration lacks commands."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage: 'commands' is a"
+ " required property\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_warn_schema_commands_is_not_list_or_dict(self, _):
+ """Warn when ubuntu-advantage:commands config is not a list or dict."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': 'broken'}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage.commands: 'broken' is"
+ " not of type 'object', 'array'\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_warn_schema_when_commands_is_empty(self, _):
+ """Emit warnings when ubuntu-advantage:commands is empty."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': []}}, schema)
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': {}}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage.commands: [] is too"
+ " short\nWARNING: Invalid config:\nubuntu-advantage.commands: {}"
+ " does not have enough properties\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_schema_when_commands_are_list_or_dict(self, _):
+ """No warnings when ubuntu-advantage:commands are a list or dict."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': ['valid']}}, schema)
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': {'01': 'also valid'}}}, schema)
+ self.assertEqual('', self.logs.getvalue())
+
+
+class TestHandle(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestHandle, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('%s.run_commands' % MPATH)
+ @mock.patch('%s.validate_cloudconfig_schema' % MPATH)
+ def test_handle_no_config(self, m_schema, m_run):
+ """When no ua-related configuration is provided, nothing happens."""
+ cfg = {}
+ handle('ua-test', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertIn(
+ "DEBUG: Skipping module named ua-test, no 'ubuntu-advantage' key"
+ " in config",
+ self.logs.getvalue())
+ m_schema.assert_not_called()
+ m_run.assert_not_called()
+
+ @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+ def test_handle_tries_to_install_ubuntu_advantage_tools(self, m_install):
+ """If ubuntu_advantage is provided, try installing ua-tools package."""
+ cfg = {'ubuntu-advantage': {}}
+ mycloud = FakeCloud(None)
+ handle('nomatter', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
+ m_install.assert_called_once_with(mycloud)
+
+ @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+ def test_handle_runs_commands_provided(self, m_install):
+ """When commands are specified as a list, run them."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cfg = {
+ 'ubuntu-advantage': {'commands': ['echo "HI" >> %s' % outfile,
+ 'echo "MOM" >> %s' % outfile]}}
+ mock_path = '%s.sys.stderr' % MPATH
+ with mock.patch(mock_path, new_callable=StringIO):
+ handle('nomatter', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+
+class TestMaybeInstallUATools(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestMaybeInstallUATools, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_tools_noop_when_ua_tools_present(self, m_which):
+ """Do nothing if ubuntu-advantage-tools already exists."""
+ m_which.return_value = '/usr/bin/ubuntu-advantage' # already installed
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ maybe_install_ua_tools(cloud=FakeCloud(distro)) # No RuntimeError
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_tools_raises_update_errors(self, m_which):
+ """maybe_install_ua_tools logs and raises apt update errors."""
+ m_which.return_value = None
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_ua_tools(cloud=FakeCloud(distro))
+ self.assertEqual('Some apt error', str(context_manager.exception))
+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_raises_install_errors(self, m_which):
+ """maybe_install_ua_tools logs and raises package install errors."""
+ m_which.return_value = None
+ distro = mock.MagicMock()
+ distro.update_package_sources.return_value = None
+ distro.install_packages.side_effect = RuntimeError(
+ 'Some install error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_ua_tools(cloud=FakeCloud(distro))
+ self.assertEqual('Some install error', str(context_manager.exception))
+ self.assertIn(
+ 'Failed to install ubuntu-advantage-tools\n', self.logs.getvalue())
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_tools_happy_path(self, m_which):
+ """maybe_install_ua_tools installs ubuntu-advantage-tools."""
+ m_which.return_value = None
+ distro = mock.MagicMock() # No errors raised
+ maybe_install_ua_tools(cloud=FakeCloud(distro))
+ distro.update_package_sources.assert_called_once_with()
+ distro.install_packages.assert_called_once_with(
+ ['ubuntu-advantage-tools'])
+
+# vi: ts=4 expandtab