← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~chad.smith/cloud-init:feature/cc-uaclient into cloud-init:master

 

Chad Smith has proposed merging ~chad.smith/cloud-init:feature/cc-uaclient into cloud-init:master.

Commit message:
ubuntu_advantage: update UA cloud-config module schema for ver.18

ubuntu-advantage-tools version 18 has a different command line interface.
Update cloud-init's config module to accept new ubuntu_advantage
configuration settings.

* Underscores better than hyphens: deprecate 'ubuntu-advantage' cloud-config key in favor of 'ubuntu_advantage'
* Attach machines with either sso credentials of UA user_token
* Entitlements are enabled by name though an 'entitlements' list
* raise errors if deprecated ubuntu-advantage config keys are present

Ubuntu Advantage support can now be configured via #cloud-config with the
following yaml:

ubuntu_advantage:
  sso_email: mine@xxxxxx
  sso_password: this4now
  sso_twofactor: 1234567890
  entitlements: [esm, fips, livepatch]

Requested reviews:
  cloud-init commiters (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/362161
-- 
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:feature/cc-uaclient into cloud-init:master.
diff --git a/cloudinit/config/cc_ubuntu_advantage.py b/cloudinit/config/cc_ubuntu_advantage.py
index 5e082bd..1a95766 100644
--- a/cloudinit/config/cc_ubuntu_advantage.py
+++ b/cloudinit/config/cc_ubuntu_advantage.py
@@ -1,145 +1,170 @@
-# 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."""
+"""ubuntu_advantage: Configure Ubuntu Advantage support entitlements"""
 
-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 import log as logging
 from cloudinit.settings import PER_INSTANCE
-from cloudinit.subp import prepend_base_command
 from cloudinit import util
 
 
-distros = ['ubuntu']
-frequency = PER_INSTANCE
+UA_URL = 'https://ubuntu.com/advantage'
 
-LOG = logging.getLogger(__name__)
+distros = ['ubuntu']
 
 schema = {
     'id': 'cc_ubuntu_advantage',
-    'name': 'Ubuntu Advantage',
-    'title': 'Install, configure and manage ubuntu-advantage offerings',
+    'name': 'UbuntuAdvantage',
+    'title': 'Configure Ubuntu Advantage support entitlements',
     '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.
+        Attach machine to an existing Ubuntu Advantage support contract and
+        enable or disable support entitlements such as livepatch, ESM,
+        FIPS, FIPS Updates and CIS Audit tools. When attaching a machine to
+        Advantage, one can either specify explicit entitlements to enable or
+        rely on the entitlement default behavior. When no 'entitlements' list
+        is provided, the default behavior enables both livepatch and esm on
+        supported Ubuntu environments.
+        When 'entitlements' list is present, any named entitlement will be
+        enabled and all absent entitlements will remain disabled.
+        Note when enabling FIPS or FIPS updates a reboot will occur after
+        installation completes to ensure the machine is running the
+        FIPS-compliant kernel.
         """),
     'distros': distros,
     'examples': [dedent("""\
-        # Enable Extended Security Maintenance using your service auth token
-        ubuntu-advantage:
-            commands:
-              00: ubuntu-advantage enable-esm <token>
+        # Attach the machine to a Ubuntu Advantage support contract with a
+        # UA user token obtained from %s.
+        # Default entitlemtents such as livepatch and esm will automatically
+        # be enabled after detachment because no entitlements were specified.
+        ubuntu_advantage:
+          token: <ua_user_token>
+    """ % UA_URL), dedent("""\
+        # Attach the machine to an Ubuntu Advantage support contract using
+        # Ubuntu SSO with optional two-factor authentication. Default
+        # entitlements such as livepatch and esm will be enabled if applicable.
+        ubuntu_advantage:
+          sso_email: <sso_email>
+          sso_password: <sso_password_hash>
+          sso_twofactor: <2fa_code>  # if the sso account requires 2fa
     """), dedent("""\
-        # Enable livepatch by providing your livepatch token
+        # Attach the machine to an Ubuntu Advantage support contract enabling
+        # only fips and entitlements. Entitlementswill only be enabled if
+        # the environment supports said entitlement. Otherwise warnings will
+        # be logged for incompatible entitlements specified.
         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'
+          token: <ua_user_token>
+          entitlements:
+          - fips
+          - esm
     """)],
     'frequency': PER_INSTANCE,
     'type': 'object',
     'properties': {
-        'ubuntu-advantage': {
+        'ubuntu_advantage': {
             'type': 'object',
             'properties': {
-                'commands': {
-                    'type': ['object', 'array'],  # Array of strings or dict
+                'entitlements': {
+                    'type': 'array',
                     'items': {
-                        'oneOf': [
-                            {'type': 'array', 'items': {'type': 'string'}},
-                            {'type': 'string'}]
+                        'type': 'string',
+                        'enum': ['esm', 'fips', 'fips-updates', 'livepatch',
+                                 'cis-audit']
                     },
-                    'additionalItems': False,  # Reject non-string & non-list
-                    'minItems': 1,
-                    'minProperties': 1,
+                    'minItems': 0
+                },
+                'sso_email': {
+                    'type': 'string',
+                    'description': 'SSO email for the UA account'
+                },
+                'sso_password': {
+                    'type': 'string',
+                    'description': 'Hashed SSO password for the UA account'
+                },
+                'sso_twofactor': {
+                    'type': 'string',
+                    'description':
+                        'Optional Two-factor authentication code if'
+                        ' required on this UA account'
+                },
+                'token': {
+                    'type': 'string',
+                    'description': "A user-token obtained from %s." % UA_URL
                 }
             },
-            'additionalProperties': False,  # Reject keys not in schema
-            'required': ['commands']
+            'oneOf': [  # Either sso_* credentials or token, but not both
+                {'required': ['sso_email', 'sso_password'],
+                 'not': {'required': ['token']}},
+                {'required': ['token'],
+                 'not': {'required': [
+                             'sso_email', 'sso_password', 'sso_twofactor']}}
+            ],
+            'minProperties': 1,  # Either token or sso_* creds must be provided
+            'additionalProperties': False
         }
     }
 }
 
-# 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.
+LOG = logging.getLogger(__name__)
 
-     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))
+def configure_ua(token=None, sso_email=None, sso_password=None,
+                 sso_twofactor=None, entitlements=None):
+    """Call ua commandline client to attach or enable entitlements."""
+    sso_auth = any([sso_email, sso_password, sso_twofactor])
+    error = None
+    if not any([token, sso_auth]):
+        error = ('ubuntu_advantage: either token or sso_email and sso_password'
+                 ' must be provided')
+    elif token and sso_auth:
+        error = ('ubuntu_advantage: token and sso credentials cannot both be'
+                 ' provided')
+    elif sso_auth and not all([sso_email, sso_password]):
+        error = ('ubuntu_advantage: both sso_email and sso_password are'
+                 ' required')
+    if error:
+        LOG.error(error)
+        raise RuntimeError(error)
+    attach_cmd = ['ua', 'attach']
+    if token:
+        attach_cmd.append(token)
+    else:
+        attach_cmd.extend(['--email', sso_email, '--password', sso_password])
+        if sso_twofactor:
+            attach_cmd.extend(['--otp', sso_twofactor])
+
+    entitlement_cmds = []
+    if entitlements is not None:  # Entitlements explicitly enabled
+        attach_cmd.append('--no-auto-enable')
+        entitlement_cmds.extend(
+            [['ua', 'enable', name] for name in entitlements])
+
+    msg = 'Attaching to Ubuntu Advantage. %s' % ' '.join(attach_cmd)
+    if sso_password:
+        msg = msg.replace(sso_password, '<REDACTED>')
+    LOG.debug(msg)
+    try:
+        util.subp(attach_cmd)
+    except util.ProcessExecutionError as e:
+        error = str(e)
+        if sso_password:
+            error = error.replace(sso_password, '<REDACTED>')
+        msg = 'Failure attaching ubuntu advantage:\n{error}'.format(
+            error=error)
         util.logexc(LOG, msg)
         raise RuntimeError(msg)
+    for cmd in entitlement_cmds:
+        try:
+            util.subp(cmd, capture=True)
+        except util.ProcessExecutionError as e:
+            msg = 'Failure enabling ubuntu advantage:\n{error}'.format(
+                error=str(e))
+            util.logexc(LOG, msg)
+            raise RuntimeError(msg)
 
 
 def maybe_install_ua_tools(cloud):
@@ -159,14 +184,29 @@ def maybe_install_ua_tools(cloud):
 
 
 def handle(name, cfg, cloud, log, args):
-    cfgin = cfg.get('ubuntu-advantage')
-    if cfgin is None:
+    if 'ubuntu-advantage' in cfg:
+        msg = ('Deprecated configuration key "ubuntu-advantage" provided.'
+               ' Expected underscore delimited "ubuntu_advantage"')
+        LOG.error(msg)
+        raise RuntimeError(msg)
+    ua_section = cfg.get('ubuntu_advantage')
+    if ua_section is None:
         LOG.debug(("Skipping module named %s,"
-                   " no 'ubuntu-advantage' key in configuration"), name)
+                   " no 'ubuntu_advantage' configuration found"), name)
         return
-
     validate_cloudconfig_schema(cfg, schema)
+    if 'commands' in ua_section:
+        msg = (
+            'Deprecated configuration "ubuntu_advantage: commands" provided.'
+            ' Expected "token" or "sso_email" and "sso_password"')
+        LOG.error(msg)
+        raise RuntimeError(msg)
+
     maybe_install_ua_tools(cloud)
-    run_commands(cfgin.get('commands', []))
+    configure_ua(token=ua_section.get('token'),
+                 sso_email=ua_section.get('sso_email'),
+                 sso_password=ua_section.get('sso_password'),
+                 sso_twofactor=ua_section.get('sso_twofactor'),
+                 entitlements=ua_section.get('entitlements'))
 
 # vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_ubuntu_advantage.py b/cloudinit/config/tests/test_ubuntu_advantage.py
index b7cf9be..53c8f67 100644
--- a/cloudinit/config/tests/test_ubuntu_advantage.py
+++ b/cloudinit/config/tests/test_ubuntu_advantage.py
@@ -1,10 +1,7 @@
 # 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)
+    configure_ua, handle, maybe_install_ua_tools, schema)
 from cloudinit.config.schema import validate_cloudconfig_schema
 from cloudinit import util
 from cloudinit.tests.helpers import (
@@ -20,90 +17,132 @@ class FakeCloud(object):
         self.distro = distro
 
 
-class TestRunCommands(CiTestCase):
+class TestConfigureUA(CiTestCase):
 
     with_logs = True
     allowed_subp = [CiTestCase.SUBP_SHELL_TRUE]
 
     def setUp(self):
-        super(TestRunCommands, self).setUp()
+        super(TestConfigureUA, self).setUp()
         self.tmp = self.tmp_dir()
 
+    def test_configure_ua_error_on_token_and_sso_creds(self):
+        """When provided with both token and any sso params, raise an error."""
+        errors = []
+        with self.assertRaises(RuntimeError) as ctx_mgr:
+            configure_ua(token='token', sso_password='pass')
+        errors.append(str(ctx_mgr.exception))
+        with self.assertRaises(RuntimeError) as ctx_mgr:
+            configure_ua(token='token', sso_email='email')
+        errors.append(str(ctx_mgr.exception))
+        with self.assertRaises(RuntimeError) as ctx_mgr:
+            configure_ua(token='token', sso_twofactor='twofactor')
+        errors.append(str(ctx_mgr.exception))
+        expected = ('ubuntu_advantage: token and sso credentials cannot both'
+                    ' be provided')
+        self.assertEqual([expected]*3, errors)
+        self.assertIn('ERROR: %s' % expected, self.logs.getvalue())
+
     @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")
+    def test_configure_ua_attach_error(self, m_subp):
+        """Errors from ua attach command are raised."""
+        m_subp.side_effect = util.ProcessExecutionError(
+            'Invalid token SomeToken')
+        with self.assertRaises(RuntimeError) as context_manager:
+            configure_ua(token='SomeToken')
         self.assertEqual(
-            "commands parameter was not a list or dict: I'm Not Valid",
+            'Failure attaching ubuntu advantage:\nUnexpected error while'
+            ' running command.\nCommand: -\nExit code: -\nReason: -\n'
+            'Stdout: Invalid token SomeToken\nStderr: -',
             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)
+    @mock.patch('%s.util.subp' % MPATH)
+    def test_configure_ua_attach_with_token(self, m_subp):
+        """When token is provided, attach the machine to ua using the token."""
+        configure_ua(token='SomeToken')
+        m_subp.assert_called_once_with(['ua', 'attach', 'SomeToken'])
+        self.assertEqual(
+            'DEBUG: Attaching to Ubuntu Advantage. ua attach SomeToken\n',
+            self.logs.getvalue())
 
-        self.assertIn(
-            'DEBUG: Running user-provided ubuntu-advantage commands',
+    @mock.patch('%s.util.subp' % MPATH)
+    def test_configure_ua_attach_with_sso_email_and_password(self, m_subp):
+        """Attach the machine to ua using sso credentials as provided."""
+        configure_ua(sso_email='me@xxxxxx', sso_password='passw0rd')
+        m_subp.assert_called_once_with([
+            'ua', 'attach', '--email', 'me@xxxxxx', '--password', 'passw0rd'
+        ])
+        self.assertEqual(
+            'DEBUG: Attaching to Ubuntu Advantage. ua attach --email'
+            ' me@xxxxxx --password <REDACTED>\n',
+            self.logs.getvalue())
+
+    @mock.patch('%s.util.subp' % MPATH)
+    def test_configure_ua_attach_with_sso_email_password_and_2fa(self, m_subp):
+        """Attach the machine to ua with sso two-factor auth."""
+        configure_ua(
+            sso_email='me@xxxxxx', sso_password='passw0rd',
+            sso_twofactor='2FA')
+        m_subp.assert_called_once_with([
+            'ua', 'attach', '--email', 'me@xxxxxx', '--password', 'passw0rd',
+            '--otp', '2FA'])
+        self.assertEqual(
+            'DEBUG: Attaching to Ubuntu Advantage. ua attach --email'
+            ' me@xxxxxx --password <REDACTED> --otp 2FA\n',
             self.logs.getvalue())
-        self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+    @mock.patch('%s.util.subp' % MPATH)
+    def test_configure_ua_attach_on_entitlement_error(self, m_subp):
+        """When enabling entitlements errors, raise that error."""
+
+        def fake_subp(cmd, capture=None):
+            if cmd == ['ua', 'enable', 'esm'] and capture:
+                raise util.ProcessExecutionError(
+                    'Invalid ESM credentials')
+
+        m_subp.side_effect = fake_subp
+
+        with self.assertRaises(RuntimeError) as context_manager:
+            configure_ua(token='SomeToken', entitlements=['fips', 'esm'])
+        self.assertEqual(
+            m_subp.call_args_list,
+            [mock.call(['ua', 'attach', 'SomeToken', '--no-auto-enable']),
+             mock.call(['ua', 'enable', 'fips'], capture=True),
+             mock.call(['ua', 'enable', 'esm'], capture=True)])
         self.assertIn(
-            'WARNING: Non-ubuntu-advantage commands in ubuntu-advantage'
-            ' config:',
+            'WARNING: Failure enabling ubuntu advantage:\nUnexpected error'
+            ' while running command.\nCommand: -\nExit code: -\nReason: -\n'
+            'Stdout: Invalid ESM credentials\nStderr: -\n',
             self.logs.getvalue())
+        self.assertEqual(
+            'Failure enabling ubuntu advantage:\nUnexpected error while'
+            ' running command.\nCommand: -\nExit code: -\nReason: -\nStdout:'
+            ' Invalid ESM credentials\nStderr: -',
+            str(context_manager.exception))
 
-    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)
+    @mock.patch('%s.util.subp' % MPATH)
+    def test_configure_ua_attach_with_empty_entitlements(self, m_subp):
+        """When entitlements is an empty list, do not auto-enable attach."""
+        configure_ua(token='SomeToken', entitlements=[])
+        m_subp.assert_called_once_with([
+            'ua', 'attach', 'SomeToken', '--no-auto-enable'])
+        self.assertEqual(
+            'DEBUG: Attaching to Ubuntu Advantage.'
+            ' ua attach SomeToken --no-auto-enable\n',
+            self.logs.getvalue())
 
-        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))
+    @mock.patch('%s.util.subp' % MPATH)
+    def test_configure_ua_attach_with_specific_entitlements(self, m_subp):
+        """When entitlements a list, only enable specific entitlements."""
+        configure_ua(token='SomeToken', entitlements=['fips'])
+        self.assertEqual(
+            m_subp.call_args_list,
+            [mock.call(['ua', 'attach', 'SomeToken', '--no-auto-enable']),
+             mock.call(['ua', 'enable', 'fips'], capture=True)])
+        self.assertEqual(
+            'DEBUG: Attaching to Ubuntu Advantage.'
+            ' ua attach SomeToken --no-auto-enable\n',
+            self.logs.getvalue())
 
 
 @skipUnlessJsonSchema()
@@ -112,90 +151,70 @@ class TestSchema(CiTestCase, SchemaTestCaseMixin):
     with_logs = True
     schema = schema
 
-    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)
+    @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+    @mock.patch('%s.configure_ua' % MPATH)
+    def test_schema_warns_on_ubuntu_advantage_not_dict(self, _cfg, _):
+        """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",
+            "WARNING: Invalid config:\nubuntu_advantage: 'wrong type' is not"
+            ' valid under any of the given schemas\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."""
+    @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+    @mock.patch('%s.configure_ua' % MPATH)
+    def test_schema_disallows_unknown_keys(self, _cfg, _):
+        """Unknown keys in ubuntu_advantage configuration emit warnings."""
         validate_cloudconfig_schema(
-            {'ubuntu-advantage': {'commands': ['ls'], 'invalid-key': ''}},
+            {'ubuntu_advantage': {'token': 'winner', 'invalid-key': ''}},
             schema)
         self.assertIn(
-            'WARNING: Invalid config:\nubuntu-advantage: Additional properties'
+            '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."""
+    @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+    @mock.patch('%s.configure_ua' % MPATH)
+    def test_warn_schema_requires_token_or_sso_credentials(self, _cfg, _):
+        """Warn if ubuntu_advantage configuration lacks token or sso creds."""
         validate_cloudconfig_schema(
-            {'ubuntu-advantage': {}}, schema)
+            {'ubuntu_advantage': {'entitlements': ['esm']}}, schema)
         self.assertEqual(
-            "WARNING: Invalid config:\nubuntu-advantage: 'commands' is a"
-            " required property\n",
-            self.logs.getvalue())
+            "WARNING: Invalid config:\nubuntu_advantage:"
+            " {'entitlements': ['esm']} is not valid under any of the given"
+            " schemas\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."""
+    @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+    @mock.patch('%s.configure_ua' % MPATH)
+    def test_warn_schema_entitlements_is_not_list_or_dict(self, _cfg, _):
+        """Warn when ubuntu_advantage:entitlements config is not a list."""
         validate_cloudconfig_schema(
-            {'ubuntu-advantage': {'commands': 'broken'}}, schema)
+            {'ubuntu_advantage': {'entitlements': 'needslist'}}, schema)
         self.assertEqual(
-            "WARNING: Invalid config:\nubuntu-advantage.commands: 'broken' is"
-            " not of type 'object', 'array'\n",
+            "WARNING: Invalid config:\nubuntu_advantage: {'entitlements':"
+            " 'needslist'} is not valid under any of the given schemas\n"
+            "ubuntu_advantage.entitlements: 'needslist' is not of type"
+            " '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)
+    @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+    @mock.patch('%s.configure_ua' % MPATH)
+    def test_warn_schema_entitlements_items_are_invalid_names(self, _cfg, _):
+        """Warn when ubuntu_advantage:entitlements items are invalid."""
         validate_cloudconfig_schema(
-            {'ubuntu-advantage': {'commands': {}}}, schema)
+            {'ubuntu_advantage': {'entitlements': ['nope', 'esm', 'nope2']}},
+            schema)
         self.assertEqual(
-            "WARNING: Invalid config:\nubuntu-advantage.commands: [] is too"
-            " short\nWARNING: Invalid config:\nubuntu-advantage.commands: {}"
-            " does not have enough properties\n",
+            "WARNING: Invalid config:\nubuntu_advantage: {'entitlements':"
+            " ['nope', 'esm', 'nope2']} is not valid under any of the given"
+            " schemas\nubuntu_advantage.entitlements.0: 'nope'"
+            " is not one of ['esm', 'fips', 'fips-updates', 'livepatch',"
+            " 'cis-audit']\nubuntu_advantage.entitlements.2: 'nope2' is not"
+            " one of ['esm', 'fips', 'fips-updates', 'livepatch',"
+            " 'cis-audit']\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())
-
-    def test_duplicates_are_fine_array_array(self):
-        """Duplicated commands array/array entries are allowed."""
-        self.assertSchemaValid(
-            {'commands': [["echo", "bye"], ["echo" "bye"]]},
-            "command entries can be duplicate.")
-
-    def test_duplicates_are_fine_array_string(self):
-        """Duplicated commands array/string entries are allowed."""
-        self.assertSchemaValid(
-            {'commands': ["echo bye", "echo bye"]},
-            "command entries can be duplicate.")
-
-    def test_duplicates_are_fine_dict_array(self):
-        """Duplicated commands dict/array entries are allowed."""
-        self.assertSchemaValid(
-            {'commands': {'00': ["echo", "bye"], '01': ["echo", "bye"]}},
-            "command entries can be duplicate.")
-
-    def test_duplicates_are_fine_dict_string(self):
-        """Duplicated commands dict/string entries are allowed."""
-        self.assertSchemaValid(
-            {'commands': {'00': "echo bye", '01': "echo bye"}},
-            "command entries can be duplicate.")
-
 
 class TestHandle(CiTestCase):
 
@@ -205,41 +224,60 @@ class TestHandle(CiTestCase):
         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):
+    def test_handle_no_config(self, m_schema):
         """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",
+            "DEBUG: Skipping module named ua-test, no 'ubuntu_advantage'"
+            ' configuration found',
             self.logs.getvalue())
         m_schema.assert_not_called()
-        m_run.assert_not_called()
 
+    @mock.patch('%s.configure_ua' % MPATH)
     @mock.patch('%s.maybe_install_ua_tools' % MPATH)
-    def test_handle_tries_to_install_ubuntu_advantage_tools(self, m_install):
+    def test_handle_tries_to_install_ubuntu_advantage_tools(
+            self, m_install, m_cfg):
         """If ubuntu_advantage is provided, try installing ua-tools package."""
-        cfg = {'ubuntu-advantage': {}}
+        cfg = {'ubuntu_advantage': {'token': 'valid'}}
         mycloud = FakeCloud(None)
         handle('nomatter', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
         m_install.assert_called_once_with(mycloud)
 
+    @mock.patch('%s.configure_ua' % MPATH)
     @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 self.allow_subp([CiTestCase.SUBP_SHELL_TRUE]):
-            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))
+    def test_handle_passes_credentials_and_entitlements_to_configure_ua(
+            self, m_install, m_configure_ua):
+        """All ubuntu_advantage config keys are passed to configure_ua."""
+        cfg = {'ubuntu_advantage': {
+                   'token': 'token', 'sso_email': 'email',
+                   'sso_password': 'pw', 'sso_twofactor': '2fa',
+                   'entitlements': ['esm']}}
+        handle('nomatter', cfg=cfg, cloud=None, log=self.logger, args=None)
+        m_configure_ua.assert_called_once_with(
+            token='token', sso_email='email', sso_password='pw',
+            sso_twofactor='2fa', entitlements=['esm'])
+
+    def test_handle_error_on_deprecated_ubuntu_advantage_key(self):
+        """Error when hyphenated ubuntu-advantage key is present."""
+        cfg = {'ubuntu-advantage': {}}
+        with self.assertRaises(RuntimeError) as context_manager:
+            handle('nomatter', cfg=cfg, cloud=None, log=self.logger, args=None)
+        self.assertEqual(
+            'Deprecated configuration key "ubuntu-advantage" provided.'
+            ' Expected underscore delimited "ubuntu_advantage"',
+            str(context_manager.exception))
+
+    def test_handle_error_on_deprecated_ubuntu_advantage_commands_key(self):
+        """Error when hyphenated ubuntu-advantage:commands is present."""
+        cfg = {'ubuntu_advantage': {'commands': 'nogo'}}
+        with self.assertRaises(RuntimeError) as context_manager:
+            handle('nomatter', cfg=cfg, cloud=None, log=self.logger, args=None)
+        self.assertEqual(
+            'Deprecated configuration "ubuntu_advantage: commands" provided.'
+            ' Expected "token" or "sso_email" and "sso_password"',
+            str(context_manager.exception))
 
 
 class TestMaybeInstallUATools(CiTestCase):

Follow ups