← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~chad.smith/cloud-init:feature/ubuntu-advantage-module into cloud-init:master

 

Chad Smith has proposed merging ~chad.smith/cloud-init:feature/ubuntu-advantage-module into cloud-init:master with ~chad.smith/cloud-init:feature/snap-module as a prerequisite.

Commit message:
ubuntu-advantage: Add new config module to support ubuntu-advantage-tools

ubuntu-advantage-tools is a package for enabling and disabling extended
support services such as Extended Security Maintenance (ESM), Canonical
Livepatch and FIPS certified PPAs. Simplify Ubuntu Advantage setup on
machines by allowing users to provide a list of ubuntu-advantage commands
in cloud-config.

Requested reviews:
  Server Team CI bot (server-team-bot): continuous-integration
  cloud-init commiters (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/341543

see commit message.

to test:
1. make a deb of this branch
make deb;
2. create a container and install the deb
lxc launch ubuntu-daily/bionic myb1;
lxc file push cloud-init_18*deb myb1/;
lxc exec myb1 -- dpkg -i /cloud-init*deb;

3. install snap user-data cloud-config
cat > ua.yaml <<EOF#cloud-config
ubuntu-advantage:
  commands:
    - echo 'hi mom'
    - [status]
    - [enable-livepatch, <your-livepatch-token>]
EOF

lxc file push ua.yaml myb1/var/lib/cloud/seed/nocloud-net/user-data;

4. clean boot the container so cloud-init runs 'fresh'
lxc exec myb1 -- cloud-init clean --reboot --logs
5. validate
lxc exec myb1 bash
-- 
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:feature/ubuntu-advantage-module into cloud-init:master.
diff --git a/cloudinit/config/cc_snap.py b/cloudinit/config/cc_snap.py
index db96529..0931031 100644
--- a/cloudinit/config/cc_snap.py
+++ b/cloudinit/config/cc_snap.py
@@ -240,7 +240,6 @@ def run_commands(commands):
         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():
diff --git a/cloudinit/config/cc_snap_config.py b/cloudinit/config/cc_snap_config.py
index afe297e..955b631 100644
--- a/cloudinit/config/cc_snap_config.py
+++ b/cloudinit/config/cc_snap_config.py
@@ -4,14 +4,13 @@
 #
 # 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.
+in cloud-init 18.2.
 
 This module uses the same ``snappy`` namespace for configuration but
 acts only only a subset of the configuration.
@@ -159,7 +158,7 @@ def handle(name, cfg, cloud, log, args):
         return
 
     log.warning(
-        'DEPRECATION: snap_config module will be dropped in 18.3 release.'
+        'DEPRECATION: snap_config module will be dropped in 18.2 release.'
         ' Use snap module instead')
     if not(util.system_is_snappy()):
         LOG.debug("%s: system not snappy", name)
diff --git a/cloudinit/config/cc_snappy.py b/cloudinit/config/cc_snappy.py
index bab80bb..bb0fcbc 100644
--- a/cloudinit/config/cc_snappy.py
+++ b/cloudinit/config/cc_snappy.py
@@ -1,13 +1,12 @@
 # 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.
+in cloud-init 18.2.
 
 The below example config config would install ``etcd``, and then install
 ``pkg2.smoser`` with a ``<config-file>`` argument where ``config-file`` has
@@ -276,7 +275,7 @@ def handle(name, cfg, cloud, log, args):
         return
 
     log.warning(
-        'DEPRECATION: snappy module will be dropped in 18.3 release.'
+        'DEPRECATION: snappy module will be dropped in 18.2 release.'
         ' Use snap module instead')
 
     set_snappy_command()
diff --git a/cloudinit/config/cc_ubuntu_advantage.py b/cloudinit/config/cc_ubuntu_advantage.py
new file mode 100644
index 0000000..e941cc0
--- /dev/null
+++ b/cloudinit/config/cc_ubuntu_advantage.py
@@ -0,0 +1,217 @@
+# 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 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-advanage 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: 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 prepend_ubuntu_advantage_commands(commands):
+    """Ensure user-provided commands start with UA_CMD, warn otherwise.
+
+    Each command is either a list or string. Perform the following:
+       - When the command is a list, pop the first element if it is None
+       - When the command is a list, insert UA_CMD as the first element if
+         not present.
+       - When the command is a string containing a non-ua command, warn.
+
+    Support cut-n-paste ubuntu-advantage command sets from public docs.
+    Allow flexibility to provide non-ua environment/config setup if needed.
+
+    @commands: List of commands. Each command element is a list or string.
+
+    @return: List of 'fixed up' ubuntu-advantage commands.
+    @raise: TypeError on invalid config item type.
+    """
+    warnings = []
+    errors = []
+    fixed_commands = []
+    for command in commands:
+        if isinstance(command, list):
+            if command[0] is None:  # Avoid warnings by specifying None
+                command = command[1:]
+            elif command[0] != UA_CMD:  # Automatically prepend UA_CMD
+                command.insert(0, UA_CMD)
+        elif isinstance(command, str):
+            if not command.startswith('%s ' % UA_CMD):
+                warnings.append(command)
+        else:
+            errors.append(str(command))
+            continue
+        fixed_commands.append(command)
+
+    if warnings:
+        LOG.warning(
+            'Non-ubuntu-advantage commands in ubuntu-advantage config:\n%s',
+            '\n'.join(warnings))
+    if errors:
+        raise TypeError(
+            'Invalid ubuntu-advantage config.'
+            ' These commands are not a string or list:\n' + '\n'.join(errors))
+    return fixed_commands
+
+
+def run_commands(commands):
+    """Run the provided 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_ubuntu_advantage_commands(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_ubuntu_advantage.py b/cloudinit/config/tests/test_ubuntu_advantage.py
new file mode 100644
index 0000000..476ff1d
--- /dev/null
+++ b/cloudinit/config/tests/test_ubuntu_advantage.py
@@ -0,0 +1,318 @@
+# 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, prepend_ubuntu_advantage_commands, 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, wrap_and_call
+
+
+# Module path used in mocks
+MPATH = 'cloudinit.config.cc_ubuntu_advantage'
+
+class FakeCloud(object):
+    def __init__(self, distro):
+        self.distro = distro
+
+
+class TestPrependUbuntuAdvantageCommands(CiTestCase):
+
+    with_logs = True
+
+    def test_prepend_ua_commands_errors_on_neither_string_nor_list(self):
+        """Raise an error for each command which is not a string or list."""
+        orig_commands = ['ls', 1, {'not': 'gonna work'},
+                         ['ubuntu-advantage', 'list']]
+        with self.assertRaises(TypeError) as context_manager:
+            prepend_ubuntu_advantage_commands(orig_commands)
+        self.assertEqual(
+            "Invalid ubuntu-advantage config. These commands are not a string"
+            " or list:\n1\n{'not': 'gonna work'}",
+            str(context_manager.exception))
+
+    def test_prepend_ua_commands_warns_on_non_ua_string_commands(self):
+        """Warn on each non-ubuntu-advantage command of type string."""
+        orig_commands = ['ls', 'ubuntu-advantage status', 'touch /blah']
+        fixed_commands = prepend_ubuntu_advantage_commands(orig_commands)
+        self.assertEqual(
+            'WARNING: Non-ubuntu-advantage commands in ubuntu-advantage'
+            ' config:\nls\ntouch /blah\n',
+            self.logs.getvalue())
+        self.assertEqual(orig_commands, fixed_commands)
+
+    def test_prepend_ua_commands_prepends_on_non_ua_list_commands(self):
+        """Prepend 'ubuntu-advantage' for each non-ua command of list type."""
+        orig_commands = [['ls'], ['ubuntu-advantage', 'status'],
+                         ['ubuntu-advantagea', '/blah'],  # Show exact match
+                         ['ubuntu-advantage', 'disable-esm']]
+        expected = [['ubuntu-advantage', 'ls'], ['ubuntu-advantage', 'status'],
+                    ['ubuntu-advantage', 'ubuntu-advantagea', '/blah'],
+                    ['ubuntu-advantage', 'disable-esm']]
+        fixed_commands = prepend_ubuntu_advantage_commands(orig_commands)
+        self.assertEqual('', self.logs.getvalue())
+        self.assertEqual(expected, fixed_commands)
+
+    def test_prepend_ua_commands_removes_first_item_when_none(self):
+        """Pop the first element of a non-ubuntu-advantage command if None."""
+        orig_commands = [[None, 'ls'], ['ubuntu-advantage', 'list'],
+                         [None, 'touch', '/blah'],
+                         ['ubuntu-advantage', 'install', 'x']]
+        expected = [['ls'], ['ubuntu-advantage', 'list'],
+                    ['touch', '/blah'],
+                    ['ubuntu-advantage', 'install', 'x']]
+        fixed_commands = prepend_ubuntu_advantage_commands(orig_commands)
+        self.assertEqual('', self.logs.getvalue())
+        self.assertEqual(expected, fixed_commands)
+
+
+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))
+
+
+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)
+        self.assertEqual(
+            [mock.call(mycloud)], m_install.call_args_list)
+
+    def test_handle_runs_commands_provided(self):
+        """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]}}
+        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 RuntimerError
+
+    @mock.patch('%s.util.which' % MPATH)
+    def test_maybe_install_ua_tools_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.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_update_errors(self, m_which):
+        """maybe_install_ua_tools logs and raises package update 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))
+        self.assertEqual(
+            [mock.call()], distro.update_package_sources.call_args_list)
+        self.assertEqual(
+            [mock.call(['ubuntu-advantage-tools'])],
+            distro.install_packages.call_args_list)
+
+# vi: ts=4 expandtab

Follow ups