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

Proposed by Chad Smith
Status: Merged
Approved by: Scott Moser
Approved revision: 74bf7302fcae41dae18d87ffcdafbe05bf2e497e
Merge reported by: Scott Moser
Merged at revision: 0d51e912146b3031c458ce415b7d4cd6eb17d06e
Proposed branch: ~chad.smith/cloud-init:feature/ubuntu-advantage-module
Merge into: cloud-init:master
Prerequisite: ~chad.smith/cloud-init:feature/snap-module
Diff against target: 777 lines (+568/-99)
9 files modified
cloudinit/config/cc_snap.py (+2/-45)
cloudinit/config/cc_ubuntu_advantage.py (+173/-0)
cloudinit/config/tests/test_snap.py (+2/-54)
cloudinit/config/tests/test_ubuntu_advantage.py (+268/-0)
cloudinit/subp.py (+57/-0)
cloudinit/tests/test_subp.py (+61/-0)
config/cloud.cfg.tmpl (+3/-0)
doc/rtd/topics/modules.rst (+1/-0)
tests/unittests/test_handler/test_schema.py (+1/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Scott Moser Approve
Review via email: mp+341543@code.launchpad.net

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.

Description of the change

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

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:208aba8a772b9166d1eb4ceb5d81b0533f5bbdd3
https://jenkins.ubuntu.com/server/job/cloud-init-ci/867/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/867/rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:4ae4c89779fdefa8cff587034750b682efda71ae
https://jenkins.ubuntu.com/server/job/cloud-init-ci/874/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/874/rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

some quick review questions.

Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:51164eaa7ac9d15ce453f3fb569527ce32425063
https://jenkins.ubuntu.com/server/job/cloud-init-ci/886/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/886/rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
Simon Poirier (simpoir) wrote :

See the couple of inline comments.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:c82df125bd2a846fd3393089431a954d172baf9d
https://jenkins.ubuntu.com/server/job/cloud-init-ci/895/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/895/rebuild

review: Needs Fixing (continuous-integration)
17d556f... by Chad Smith

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.

06faa32... by Chad Smith

address simpoir's review comments: unit test fixes and doc updates

Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:06faa32e6071f673722d4c86038217f40e966c85
https://jenkins.ubuntu.com/server/job/cloud-init-ci/896/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/896/rebuild

review: Needs Fixing (continuous-integration)
3adffb5... by Chad Smith

add cc_ubuntu_advntage rtd module docs

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:3adffb5356f197b351d0c9d8d6b63c2f134d7b4e
https://jenkins.ubuntu.com/server/job/cloud-init-ci/898/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/898/rebuild

review: Needs Fixing (continuous-integration)
8db00b1... by Chad Smith

include subp module

bc3a901... by Chad Smith

avoid leaking stderr during ua unit tests

d02ef98... by Chad Smith

mock maybe_install_ua_tools in tests

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:d02ef981912dfa6f62d78639330b2c2ac515d05f
https://jenkins.ubuntu.com/server/job/cloud-init-ci/903/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/903/rebuild

review: Approve (continuous-integration)
Revision history for this message
Simon Poirier (simpoir) wrote :

Aren't you missing an entry in config/cloud.cfg.tmpl for this to run?

74bf730... by Chad Smith

add ubuntu-advantage to cloud.cfg.tmpl

Revision history for this message
Chad Smith (chad.smith) wrote :

> Aren't you missing an entry in config/cloud.cfg.tmpl for this to run?
Absolutely. you got me, and I just tried running minimal ua commands on an lxc with cloud.cfg.tmpl enabled in my latest branch and it works.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:74bf7302fcae41dae18d87ffcdafbe05bf2e497e
https://jenkins.ubuntu.com/server/job/cloud-init-ci/908/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/908/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

looks good. thanks!

review: Approve
4790747... by Chad Smith

add test_subp.py module

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:4790747ee606a23818b892d14811ba0d78dbc71d
https://jenkins.ubuntu.com/server/job/cloud-init-ci/913/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/913/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

An upstream commit landed for this bug.

To view that commit see the following URL:
https://git.launchpad.net/cloud-init/commit/?id=0d51e912

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/cc_snap.py b/cloudinit/config/cc_snap.py
2index db96529..34a53fd 100644
3--- a/cloudinit/config/cc_snap.py
4+++ b/cloudinit/config/cc_snap.py
5@@ -11,6 +11,7 @@ from cloudinit import log as logging
6 from cloudinit.config.schema import (
7 get_schema_doc, validate_cloudconfig_schema)
8 from cloudinit.settings import PER_INSTANCE
9+from cloudinit.subp import prepend_base_command
10 from cloudinit import util
11
12
13@@ -160,50 +161,6 @@ def add_assertions(assertions):
14 util.subp(snap_cmd + [ASSERTIONS_FILE], capture=True)
15
16
17-def prepend_snap_commands(commands):
18- """Ensure user-provided commands start with SNAP_CMD, warn otherwise.
19-
20- Each command is either a list or string. Perform the following:
21- - When the command is a list, pop the first element if it is None
22- - When the command is a list, insert SNAP_CMD as the first element if
23- not present.
24- - When the command is a string containing a non-snap command, warn.
25-
26- Support cut-n-paste snap command sets from public snappy documentation.
27- Allow flexibility to provide non-snap environment/config setup if needed.
28-
29- @commands: List of commands. Each command element is a list or string.
30-
31- @return: List of 'fixed up' snap commands.
32- @raise: TypeError on invalid config item type.
33- """
34- warnings = []
35- errors = []
36- fixed_commands = []
37- for command in commands:
38- if isinstance(command, list):
39- if command[0] is None: # Avoid warnings by specifying None
40- command = command[1:]
41- elif command[0] != SNAP_CMD: # Automatically prepend SNAP_CMD
42- command.insert(0, SNAP_CMD)
43- elif isinstance(command, str):
44- if not command.startswith('%s ' % SNAP_CMD):
45- warnings.append(command)
46- else:
47- errors.append(str(command))
48- continue
49- fixed_commands.append(command)
50-
51- if warnings:
52- LOG.warning(
53- 'Non-snap commands in snap config:\n%s', '\n'.join(warnings))
54- if errors:
55- raise TypeError(
56- 'Invalid snap config.'
57- ' These commands are not a string or list:\n' + '\n'.join(errors))
58- return fixed_commands
59-
60-
61 def run_commands(commands):
62 """Run the provided commands provided in snap:commands configuration.
63
64@@ -224,7 +181,7 @@ def run_commands(commands):
65 'commands parameter was not a list or dict: {commands}'.format(
66 commands=commands))
67
68- fixed_snap_commands = prepend_snap_commands(commands)
69+ fixed_snap_commands = prepend_base_command('snap', commands)
70
71 cmd_failures = []
72 for command in fixed_snap_commands:
73diff --git a/cloudinit/config/cc_ubuntu_advantage.py b/cloudinit/config/cc_ubuntu_advantage.py
74new file mode 100644
75index 0000000..16b1868
76--- /dev/null
77+++ b/cloudinit/config/cc_ubuntu_advantage.py
78@@ -0,0 +1,173 @@
79+# Copyright (C) 2018 Canonical Ltd.
80+#
81+# This file is part of cloud-init. See LICENSE file for license information.
82+
83+"""Ubuntu advantage: manage ubuntu-advantage offerings from Canonical."""
84+
85+import sys
86+from textwrap import dedent
87+
88+from cloudinit import log as logging
89+from cloudinit.config.schema import (
90+ get_schema_doc, validate_cloudconfig_schema)
91+from cloudinit.settings import PER_INSTANCE
92+from cloudinit.subp import prepend_base_command
93+from cloudinit import util
94+
95+
96+distros = ['ubuntu']
97+frequency = PER_INSTANCE
98+
99+LOG = logging.getLogger(__name__)
100+
101+schema = {
102+ 'id': 'cc_ubuntu_advantage',
103+ 'name': 'Ubuntu Advantage',
104+ 'title': 'Install, configure and manage ubuntu-advantage offerings',
105+ 'description': dedent("""\
106+ This module provides configuration options to setup ubuntu-advantage
107+ subscriptions.
108+
109+ .. note::
110+ Both ``commands`` value can be either a dictionary or a list. If
111+ the configuration provided is a dictionary, the keys are only used
112+ to order the execution of the commands and the dictionary is
113+ merged with any vendor-data ubuntu-advantage configuration
114+ provided. If a ``commands`` is provided as a list, any vendor-data
115+ ubuntu-advantage ``commands`` are ignored.
116+
117+ Ubuntu-advantage ``commands`` is a dictionary or list of
118+ ubuntu-advantage commands to run on the deployed machine.
119+ These commands can be used to enable or disable subscriptions to
120+ various ubuntu-advantage products. See 'man ubuntu-advantage' for more
121+ information on supported subcommands.
122+
123+ .. note::
124+ Each command item can be a string or list. If the item is a list,
125+ 'ubuntu-advantage' can be omitted and it will automatically be
126+ inserted as part of the command.
127+ """),
128+ 'distros': distros,
129+ 'examples': [dedent("""\
130+ # Enable Extended Security Maintenance using your service auth token
131+ ubuntu-advantage:
132+ commands:
133+ 00: ubuntu-advantage enable-esm <token>
134+ """), dedent("""\
135+ # Enable livepatch by providing your livepatch token
136+ ubuntu-advantage:
137+ commands:
138+ 00: ubuntu-advantage enable-livepatch <livepatch-token>
139+
140+ """), dedent("""\
141+ # Convenience: the ubuntu-advantage command can be omitted when
142+ # specifying commands as a list and 'ubuntu-advantage' will
143+ # automatically be prepended.
144+ # The following commands are equivalent
145+ ubuntu-advantage:
146+ commands:
147+ 00: ['enable-livepatch', 'my-token']
148+ 01: ['ubuntu-advantage', 'enable-livepatch', 'my-token']
149+ 02: ubuntu-advantage enable-livepatch my-token
150+ 03: 'ubuntu-advantage enable-livepatch my-token'
151+ """)],
152+ 'frequency': PER_INSTANCE,
153+ 'type': 'object',
154+ 'properties': {
155+ 'ubuntu-advantage': {
156+ 'type': 'object',
157+ 'properties': {
158+ 'commands': {
159+ 'type': ['object', 'array'], # Array of strings or dict
160+ 'items': {
161+ 'oneOf': [
162+ {'type': 'array', 'items': {'type': 'string'}},
163+ {'type': 'string'}]
164+ },
165+ 'additionalItems': False, # Reject non-string & non-list
166+ 'minItems': 1,
167+ 'minProperties': 1,
168+ 'uniqueItems': True
169+ }
170+ },
171+ 'additionalProperties': False, # Reject keys not in schema
172+ 'required': ['commands']
173+ }
174+ }
175+}
176+
177+# TODO schema for 'assertions' and 'commands' are too permissive at the moment.
178+# Once python-jsonschema supports schema draft 6 add support for arbitrary
179+# object keys with 'patternProperties' constraint to validate string values.
180+
181+__doc__ = get_schema_doc(schema) # Supplement python help()
182+
183+UA_CMD = "ubuntu-advantage"
184+
185+
186+def run_commands(commands):
187+ """Run the commands provided in ubuntu-advantage:commands config.
188+
189+ Commands are run individually. Any errors are collected and reported
190+ after attempting all commands.
191+
192+ @param commands: A list or dict containing commands to run. Keys of a
193+ dict will be used to order the commands provided as dict values.
194+ """
195+ if not commands:
196+ return
197+ LOG.debug('Running user-provided ubuntu-advantage commands')
198+ if isinstance(commands, dict):
199+ # Sort commands based on dictionary key
200+ commands = [v for _, v in sorted(commands.items())]
201+ elif not isinstance(commands, list):
202+ raise TypeError(
203+ 'commands parameter was not a list or dict: {commands}'.format(
204+ commands=commands))
205+
206+ fixed_ua_commands = prepend_base_command('ubuntu-advantage', commands)
207+
208+ cmd_failures = []
209+ for command in fixed_ua_commands:
210+ shell = isinstance(command, str)
211+ try:
212+ util.subp(command, shell=shell, status_cb=sys.stderr.write)
213+ except util.ProcessExecutionError as e:
214+ cmd_failures.append(str(e))
215+ if cmd_failures:
216+ msg = (
217+ 'Failures running ubuntu-advantage commands:\n'
218+ '{cmd_failures}'.format(
219+ cmd_failures=cmd_failures))
220+ util.logexc(LOG, msg)
221+ raise RuntimeError(msg)
222+
223+
224+def maybe_install_ua_tools(cloud):
225+ """Install ubuntu-advantage-tools if not present."""
226+ if util.which('ubuntu-advantage'):
227+ return
228+ try:
229+ cloud.distro.update_package_sources()
230+ except Exception as e:
231+ util.logexc(LOG, "Package update failed")
232+ raise
233+ try:
234+ cloud.distro.install_packages(['ubuntu-advantage-tools'])
235+ except Exception as e:
236+ util.logexc(LOG, "Failed to install ubuntu-advantage-tools")
237+ raise
238+
239+
240+def handle(name, cfg, cloud, log, args):
241+ cfgin = cfg.get('ubuntu-advantage')
242+ if cfgin is None:
243+ LOG.debug(("Skipping module named %s,"
244+ " no 'ubuntu-advantage' key in configuration"), name)
245+ return
246+
247+ validate_cloudconfig_schema(cfg, schema)
248+ maybe_install_ua_tools(cloud)
249+ run_commands(cfgin.get('commands', []))
250+
251+# vi: ts=4 expandtab
252diff --git a/cloudinit/config/tests/test_snap.py b/cloudinit/config/tests/test_snap.py
253index 990475f..988e7f7 100644
254--- a/cloudinit/config/tests/test_snap.py
255+++ b/cloudinit/config/tests/test_snap.py
256@@ -4,8 +4,8 @@ import re
257 from six import StringIO
258
259 from cloudinit.config.cc_snap import (
260- ASSERTIONS_FILE, add_assertions, handle, prepend_snap_commands,
261- maybe_install_squashfuse, run_commands, schema)
262+ ASSERTIONS_FILE, add_assertions, handle, maybe_install_squashfuse,
263+ run_commands, schema)
264 from cloudinit.config.schema import validate_cloudconfig_schema
265 from cloudinit import util
266 from cloudinit.tests.helpers import CiTestCase, mock, wrap_and_call
267@@ -158,54 +158,6 @@ class TestAddAssertions(CiTestCase):
268 util.load_file(compare_file), util.load_file(assert_file))
269
270
271-class TestPrepentSnapCommands(CiTestCase):
272-
273- with_logs = True
274-
275- def test_prepend_snap_commands_errors_on_neither_string_nor_list(self):
276- """Raise an error for each command which is not a string or list."""
277- orig_commands = ['ls', 1, {'not': 'gonna work'}, ['snap', 'list']]
278- with self.assertRaises(TypeError) as context_manager:
279- prepend_snap_commands(orig_commands)
280- self.assertEqual(
281- "Invalid snap config. These commands are not a string or list:\n"
282- "1\n{'not': 'gonna work'}",
283- str(context_manager.exception))
284-
285- def test_prepend_snap_commands_warns_on_non_snap_string_commands(self):
286- """Warn on each non-snap for commands of type string."""
287- orig_commands = ['ls', 'snap list', 'touch /blah', 'snap install x']
288- fixed_commands = prepend_snap_commands(orig_commands)
289- self.assertEqual(
290- 'WARNING: Non-snap commands in snap config:\n'
291- 'ls\ntouch /blah\n',
292- self.logs.getvalue())
293- self.assertEqual(orig_commands, fixed_commands)
294-
295- def test_prepend_snap_commands_prepends_on_non_snap_list_commands(self):
296- """Prepend 'snap' for each non-snap command of type list."""
297- orig_commands = [['ls'], ['snap', 'list'], ['snapa', '/blah'],
298- ['snap', 'install', 'x']]
299- expected = [['snap', 'ls'], ['snap', 'list'],
300- ['snap', 'snapa', '/blah'],
301- ['snap', 'install', 'x']]
302- fixed_commands = prepend_snap_commands(orig_commands)
303- self.assertEqual('', self.logs.getvalue())
304- self.assertEqual(expected, fixed_commands)
305-
306- def test_prepend_snap_commands_removes_first_item_when_none(self):
307- """Remove the first element of a non-snap command when it is None."""
308- orig_commands = [[None, 'ls'], ['snap', 'list'],
309- [None, 'touch', '/blah'],
310- ['snap', 'install', 'x']]
311- expected = [['ls'], ['snap', 'list'],
312- ['touch', '/blah'],
313- ['snap', 'install', 'x']]
314- fixed_commands = prepend_snap_commands(orig_commands)
315- self.assertEqual('', self.logs.getvalue())
316- self.assertEqual(expected, fixed_commands)
317-
318-
319 class TestRunCommands(CiTestCase):
320
321 with_logs = True
322@@ -444,13 +396,9 @@ class TestHandle(CiTestCase):
323 cfg = {
324 'snap': {'commands': ['echo "HI" >> %s' % outfile,
325 'echo "MOM" >> %s' % outfile]}}
326-<<<<<<< cloudinit/config/tests/test_snap.py
327 mock_path = 'cloudinit.config.cc_snap.sys.stderr'
328 with mock.patch(mock_path, new_callable=StringIO):
329 handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
330-=======
331- handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
332->>>>>>> cloudinit/config/tests/test_snap.py
333 self.assertEqual('HI\nMOM\n', util.load_file(outfile))
334
335 @mock.patch('cloudinit.config.cc_snap.util.subp')
336diff --git a/cloudinit/config/tests/test_ubuntu_advantage.py b/cloudinit/config/tests/test_ubuntu_advantage.py
337new file mode 100644
338index 0000000..0eeadd4
339--- /dev/null
340+++ b/cloudinit/config/tests/test_ubuntu_advantage.py
341@@ -0,0 +1,268 @@
342+# This file is part of cloud-init. See LICENSE file for license information.
343+
344+import re
345+from six import StringIO
346+
347+from cloudinit.config.cc_ubuntu_advantage import (
348+ handle, maybe_install_ua_tools, run_commands, schema)
349+from cloudinit.config.schema import validate_cloudconfig_schema
350+from cloudinit import util
351+from cloudinit.tests.helpers import CiTestCase, mock
352+
353+
354+# Module path used in mocks
355+MPATH = 'cloudinit.config.cc_ubuntu_advantage'
356+
357+
358+class FakeCloud(object):
359+ def __init__(self, distro):
360+ self.distro = distro
361+
362+
363+class TestRunCommands(CiTestCase):
364+
365+ with_logs = True
366+
367+ def setUp(self):
368+ super(TestRunCommands, self).setUp()
369+ self.tmp = self.tmp_dir()
370+
371+ @mock.patch('%s.util.subp' % MPATH)
372+ def test_run_commands_on_empty_list(self, m_subp):
373+ """When provided with an empty list, run_commands does nothing."""
374+ run_commands([])
375+ self.assertEqual('', self.logs.getvalue())
376+ m_subp.assert_not_called()
377+
378+ def test_run_commands_on_non_list_or_dict(self):
379+ """When provided an invalid type, run_commands raises an error."""
380+ with self.assertRaises(TypeError) as context_manager:
381+ run_commands(commands="I'm Not Valid")
382+ self.assertEqual(
383+ "commands parameter was not a list or dict: I'm Not Valid",
384+ str(context_manager.exception))
385+
386+ def test_run_command_logs_commands_and_exit_codes_to_stderr(self):
387+ """All exit codes are logged to stderr."""
388+ outfile = self.tmp_path('output.log', dir=self.tmp)
389+
390+ cmd1 = 'echo "HI" >> %s' % outfile
391+ cmd2 = 'bogus command'
392+ cmd3 = 'echo "MOM" >> %s' % outfile
393+ commands = [cmd1, cmd2, cmd3]
394+
395+ mock_path = '%s.sys.stderr' % MPATH
396+ with mock.patch(mock_path, new_callable=StringIO) as m_stderr:
397+ with self.assertRaises(RuntimeError) as context_manager:
398+ run_commands(commands=commands)
399+
400+ self.assertIsNotNone(
401+ re.search(r'bogus: (command )?not found',
402+ str(context_manager.exception)),
403+ msg='Expected bogus command not found')
404+ expected_stderr_log = '\n'.join([
405+ 'Begin run command: {cmd}'.format(cmd=cmd1),
406+ 'End run command: exit(0)',
407+ 'Begin run command: {cmd}'.format(cmd=cmd2),
408+ 'ERROR: End run command: exit(127)',
409+ 'Begin run command: {cmd}'.format(cmd=cmd3),
410+ 'End run command: exit(0)\n'])
411+ self.assertEqual(expected_stderr_log, m_stderr.getvalue())
412+
413+ def test_run_command_as_lists(self):
414+ """When commands are specified as a list, run them in order."""
415+ outfile = self.tmp_path('output.log', dir=self.tmp)
416+
417+ cmd1 = 'echo "HI" >> %s' % outfile
418+ cmd2 = 'echo "MOM" >> %s' % outfile
419+ commands = [cmd1, cmd2]
420+ with mock.patch('%s.sys.stderr' % MPATH, new_callable=StringIO):
421+ run_commands(commands=commands)
422+
423+ self.assertIn(
424+ 'DEBUG: Running user-provided ubuntu-advantage commands',
425+ self.logs.getvalue())
426+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
427+ self.assertIn(
428+ 'WARNING: Non-ubuntu-advantage commands in ubuntu-advantage'
429+ ' config:',
430+ self.logs.getvalue())
431+
432+ def test_run_command_dict_sorted_as_command_script(self):
433+ """When commands are a dict, sort them and run."""
434+ outfile = self.tmp_path('output.log', dir=self.tmp)
435+ cmd1 = 'echo "HI" >> %s' % outfile
436+ cmd2 = 'echo "MOM" >> %s' % outfile
437+ commands = {'02': cmd1, '01': cmd2}
438+ with mock.patch('%s.sys.stderr' % MPATH, new_callable=StringIO):
439+ run_commands(commands=commands)
440+
441+ expected_messages = [
442+ 'DEBUG: Running user-provided ubuntu-advantage commands']
443+ for message in expected_messages:
444+ self.assertIn(message, self.logs.getvalue())
445+ self.assertEqual('MOM\nHI\n', util.load_file(outfile))
446+
447+
448+class TestSchema(CiTestCase):
449+
450+ with_logs = True
451+
452+ def test_schema_warns_on_ubuntu_advantage_not_as_dict(self):
453+ """If ubuntu-advantage configuration is not a dict, emit a warning."""
454+ validate_cloudconfig_schema({'ubuntu-advantage': 'wrong type'}, schema)
455+ self.assertEqual(
456+ "WARNING: Invalid config:\nubuntu-advantage: 'wrong type' is not"
457+ " of type 'object'\n",
458+ self.logs.getvalue())
459+
460+ @mock.patch('%s.run_commands' % MPATH)
461+ def test_schema_disallows_unknown_keys(self, _):
462+ """Unknown keys in ubuntu-advantage configuration emit warnings."""
463+ validate_cloudconfig_schema(
464+ {'ubuntu-advantage': {'commands': ['ls'], 'invalid-key': ''}},
465+ schema)
466+ self.assertIn(
467+ 'WARNING: Invalid config:\nubuntu-advantage: Additional properties'
468+ " are not allowed ('invalid-key' was unexpected)",
469+ self.logs.getvalue())
470+
471+ def test_warn_schema_requires_commands(self):
472+ """Warn when ubuntu-advantage configuration lacks commands."""
473+ validate_cloudconfig_schema(
474+ {'ubuntu-advantage': {}}, schema)
475+ self.assertEqual(
476+ "WARNING: Invalid config:\nubuntu-advantage: 'commands' is a"
477+ " required property\n",
478+ self.logs.getvalue())
479+
480+ @mock.patch('%s.run_commands' % MPATH)
481+ def test_warn_schema_commands_is_not_list_or_dict(self, _):
482+ """Warn when ubuntu-advantage:commands config is not a list or dict."""
483+ validate_cloudconfig_schema(
484+ {'ubuntu-advantage': {'commands': 'broken'}}, schema)
485+ self.assertEqual(
486+ "WARNING: Invalid config:\nubuntu-advantage.commands: 'broken' is"
487+ " not of type 'object', 'array'\n",
488+ self.logs.getvalue())
489+
490+ @mock.patch('%s.run_commands' % MPATH)
491+ def test_warn_schema_when_commands_is_empty(self, _):
492+ """Emit warnings when ubuntu-advantage:commands is empty."""
493+ validate_cloudconfig_schema(
494+ {'ubuntu-advantage': {'commands': []}}, schema)
495+ validate_cloudconfig_schema(
496+ {'ubuntu-advantage': {'commands': {}}}, schema)
497+ self.assertEqual(
498+ "WARNING: Invalid config:\nubuntu-advantage.commands: [] is too"
499+ " short\nWARNING: Invalid config:\nubuntu-advantage.commands: {}"
500+ " does not have enough properties\n",
501+ self.logs.getvalue())
502+
503+ @mock.patch('%s.run_commands' % MPATH)
504+ def test_schema_when_commands_are_list_or_dict(self, _):
505+ """No warnings when ubuntu-advantage:commands are a list or dict."""
506+ validate_cloudconfig_schema(
507+ {'ubuntu-advantage': {'commands': ['valid']}}, schema)
508+ validate_cloudconfig_schema(
509+ {'ubuntu-advantage': {'commands': {'01': 'also valid'}}}, schema)
510+ self.assertEqual('', self.logs.getvalue())
511+
512+
513+class TestHandle(CiTestCase):
514+
515+ with_logs = True
516+
517+ def setUp(self):
518+ super(TestHandle, self).setUp()
519+ self.tmp = self.tmp_dir()
520+
521+ @mock.patch('%s.run_commands' % MPATH)
522+ @mock.patch('%s.validate_cloudconfig_schema' % MPATH)
523+ def test_handle_no_config(self, m_schema, m_run):
524+ """When no ua-related configuration is provided, nothing happens."""
525+ cfg = {}
526+ handle('ua-test', cfg=cfg, cloud=None, log=self.logger, args=None)
527+ self.assertIn(
528+ "DEBUG: Skipping module named ua-test, no 'ubuntu-advantage' key"
529+ " in config",
530+ self.logs.getvalue())
531+ m_schema.assert_not_called()
532+ m_run.assert_not_called()
533+
534+ @mock.patch('%s.maybe_install_ua_tools' % MPATH)
535+ def test_handle_tries_to_install_ubuntu_advantage_tools(self, m_install):
536+ """If ubuntu_advantage is provided, try installing ua-tools package."""
537+ cfg = {'ubuntu-advantage': {}}
538+ mycloud = FakeCloud(None)
539+ handle('nomatter', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
540+ m_install.assert_called_once_with(mycloud)
541+
542+ @mock.patch('%s.maybe_install_ua_tools' % MPATH)
543+ def test_handle_runs_commands_provided(self, m_install):
544+ """When commands are specified as a list, run them."""
545+ outfile = self.tmp_path('output.log', dir=self.tmp)
546+
547+ cfg = {
548+ 'ubuntu-advantage': {'commands': ['echo "HI" >> %s' % outfile,
549+ 'echo "MOM" >> %s' % outfile]}}
550+ mock_path = '%s.sys.stderr' % MPATH
551+ with mock.patch(mock_path, new_callable=StringIO):
552+ handle('nomatter', cfg=cfg, cloud=None, log=self.logger, args=None)
553+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
554+
555+
556+class TestMaybeInstallUATools(CiTestCase):
557+
558+ with_logs = True
559+
560+ def setUp(self):
561+ super(TestMaybeInstallUATools, self).setUp()
562+ self.tmp = self.tmp_dir()
563+
564+ @mock.patch('%s.util.which' % MPATH)
565+ def test_maybe_install_ua_tools_noop_when_ua_tools_present(self, m_which):
566+ """Do nothing if ubuntu-advantage-tools already exists."""
567+ m_which.return_value = '/usr/bin/ubuntu-advantage' # already installed
568+ distro = mock.MagicMock()
569+ distro.update_package_sources.side_effect = RuntimeError(
570+ 'Some apt error')
571+ maybe_install_ua_tools(cloud=FakeCloud(distro)) # No RuntimeError
572+
573+ @mock.patch('%s.util.which' % MPATH)
574+ def test_maybe_install_ua_tools_raises_update_errors(self, m_which):
575+ """maybe_install_ua_tools logs and raises apt update errors."""
576+ m_which.return_value = None
577+ distro = mock.MagicMock()
578+ distro.update_package_sources.side_effect = RuntimeError(
579+ 'Some apt error')
580+ with self.assertRaises(RuntimeError) as context_manager:
581+ maybe_install_ua_tools(cloud=FakeCloud(distro))
582+ self.assertEqual('Some apt error', str(context_manager.exception))
583+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
584+
585+ @mock.patch('%s.util.which' % MPATH)
586+ def test_maybe_install_ua_raises_install_errors(self, m_which):
587+ """maybe_install_ua_tools logs and raises package install errors."""
588+ m_which.return_value = None
589+ distro = mock.MagicMock()
590+ distro.update_package_sources.return_value = None
591+ distro.install_packages.side_effect = RuntimeError(
592+ 'Some install error')
593+ with self.assertRaises(RuntimeError) as context_manager:
594+ maybe_install_ua_tools(cloud=FakeCloud(distro))
595+ self.assertEqual('Some install error', str(context_manager.exception))
596+ self.assertIn(
597+ 'Failed to install ubuntu-advantage-tools\n', self.logs.getvalue())
598+
599+ @mock.patch('%s.util.which' % MPATH)
600+ def test_maybe_install_ua_tools_happy_path(self, m_which):
601+ """maybe_install_ua_tools installs ubuntu-advantage-tools."""
602+ m_which.return_value = None
603+ distro = mock.MagicMock() # No errors raised
604+ maybe_install_ua_tools(cloud=FakeCloud(distro))
605+ distro.update_package_sources.assert_called_once_with()
606+ distro.install_packages.assert_called_once_with(
607+ ['ubuntu-advantage-tools'])
608+
609+# vi: ts=4 expandtab
610diff --git a/cloudinit/subp.py b/cloudinit/subp.py
611new file mode 100644
612index 0000000..0ad0930
613--- /dev/null
614+++ b/cloudinit/subp.py
615@@ -0,0 +1,57 @@
616+# This file is part of cloud-init. See LICENSE file for license information.
617+"""Common utility functions for interacting with subprocess."""
618+
619+# TODO move subp shellify and runparts related functions out of util.py
620+
621+import logging
622+
623+LOG = logging.getLogger(__name__)
624+
625+
626+def prepend_base_command(base_command, commands):
627+ """Ensure user-provided commands start with base_command; warn otherwise.
628+
629+ Each command is either a list or string. Perform the following:
630+ - If the command is a list, pop the first element if it is None
631+ - If the command is a list, insert base_command as the first element if
632+ not present.
633+ - When the command is a string not starting with 'base-command', warn.
634+
635+ Allow flexibility to provide non-base-command environment/config setup if
636+ needed.
637+
638+ @commands: List of commands. Each command element is a list or string.
639+
640+ @return: List of 'fixed up' commands.
641+ @raise: TypeError on invalid config item type.
642+ """
643+ warnings = []
644+ errors = []
645+ fixed_commands = []
646+ for command in commands:
647+ if isinstance(command, list):
648+ if command[0] is None: # Avoid warnings by specifying None
649+ command = command[1:]
650+ elif command[0] != base_command: # Automatically prepend
651+ command.insert(0, base_command)
652+ elif isinstance(command, str):
653+ if not command.startswith('%s ' % base_command):
654+ warnings.append(command)
655+ else:
656+ errors.append(str(command))
657+ continue
658+ fixed_commands.append(command)
659+
660+ if warnings:
661+ LOG.warning(
662+ 'Non-%s commands in %s config:\n%s',
663+ base_command, base_command, '\n'.join(warnings))
664+ if errors:
665+ raise TypeError(
666+ 'Invalid {name} config.'
667+ ' These commands are not a string or list:\n{errors}'.format(
668+ name=base_command, errors='\n'.join(errors)))
669+ return fixed_commands
670+
671+
672+# vi: ts=4 expandtab
673diff --git a/cloudinit/tests/test_subp.py b/cloudinit/tests/test_subp.py
674new file mode 100644
675index 0000000..448097d
676--- /dev/null
677+++ b/cloudinit/tests/test_subp.py
678@@ -0,0 +1,61 @@
679+# This file is part of cloud-init. See LICENSE file for license information.
680+
681+"""Tests for cloudinit.subp utility functions"""
682+
683+from cloudinit import subp
684+from cloudinit.tests.helpers import CiTestCase
685+
686+
687+class TestPrependBaseCommands(CiTestCase):
688+
689+ with_logs = True
690+
691+ def test_prepend_base_command_errors_on_neither_string_nor_list(self):
692+ """Raise an error for each command which is not a string or list."""
693+ orig_commands = ['ls', 1, {'not': 'gonna work'}, ['basecmd', 'list']]
694+ with self.assertRaises(TypeError) as context_manager:
695+ subp.prepend_base_command(
696+ base_command='basecmd', commands=orig_commands)
697+ self.assertEqual(
698+ "Invalid basecmd config. These commands are not a string or"
699+ " list:\n1\n{'not': 'gonna work'}",
700+ str(context_manager.exception))
701+
702+ def test_prepend_base_command_warns_on_non_base_string_commands(self):
703+ """Warn on each non-base for commands of type string."""
704+ orig_commands = [
705+ 'ls', 'basecmd list', 'touch /blah', 'basecmd install x']
706+ fixed_commands = subp.prepend_base_command(
707+ base_command='basecmd', commands=orig_commands)
708+ self.assertEqual(
709+ 'WARNING: Non-basecmd commands in basecmd config:\n'
710+ 'ls\ntouch /blah\n',
711+ self.logs.getvalue())
712+ self.assertEqual(orig_commands, fixed_commands)
713+
714+ def test_prepend_base_command_prepends_on_non_base_list_commands(self):
715+ """Prepend 'basecmd' for each non-basecmd command of type list."""
716+ orig_commands = [['ls'], ['basecmd', 'list'], ['basecmda', '/blah'],
717+ ['basecmd', 'install', 'x']]
718+ expected = [['basecmd', 'ls'], ['basecmd', 'list'],
719+ ['basecmd', 'basecmda', '/blah'],
720+ ['basecmd', 'install', 'x']]
721+ fixed_commands = subp.prepend_base_command(
722+ base_command='basecmd', commands=orig_commands)
723+ self.assertEqual('', self.logs.getvalue())
724+ self.assertEqual(expected, fixed_commands)
725+
726+ def test_prepend_base_command_removes_first_item_when_none(self):
727+ """Remove the first element of a non-basecmd when it is None."""
728+ orig_commands = [[None, 'ls'], ['basecmd', 'list'],
729+ [None, 'touch', '/blah'],
730+ ['basecmd', 'install', 'x']]
731+ expected = [['ls'], ['basecmd', 'list'],
732+ ['touch', '/blah'],
733+ ['basecmd', 'install', 'x']]
734+ fixed_commands = subp.prepend_base_command(
735+ base_command='basecmd', commands=orig_commands)
736+ self.assertEqual('', self.logs.getvalue())
737+ self.assertEqual(expected, fixed_commands)
738+
739+# vi: ts=4 expandtab
740diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
741index 56a34fa..3129d4e 100644
742--- a/config/cloud.cfg.tmpl
743+++ b/config/cloud.cfg.tmpl
744@@ -87,6 +87,9 @@ cloud_config_modules:
745 - apt-pipelining
746 - apt-configure
747 {% endif %}
748+{% if variant in ["ubuntu"] %}
749+ - ubuntu-advantage
750+{% endif %}
751 {% if variant in ["suse"] %}
752 - zypper-add-repo
753 {% endif %}
754diff --git a/doc/rtd/topics/modules.rst b/doc/rtd/topics/modules.rst
755index a0f6812..d9720f6 100644
756--- a/doc/rtd/topics/modules.rst
757+++ b/doc/rtd/topics/modules.rst
758@@ -53,6 +53,7 @@ Modules
759 .. automodule:: cloudinit.config.cc_ssh_authkey_fingerprints
760 .. automodule:: cloudinit.config.cc_ssh_import_id
761 .. automodule:: cloudinit.config.cc_timezone
762+.. automodule:: cloudinit.config.cc_ubuntu_advantage
763 .. automodule:: cloudinit.config.cc_update_etc_hosts
764 .. automodule:: cloudinit.config.cc_update_hostname
765 .. automodule:: cloudinit.config.cc_users_groups
766diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
767index 9b50ee7..ac41f12 100644
768--- a/tests/unittests/test_handler/test_schema.py
769+++ b/tests/unittests/test_handler/test_schema.py
770@@ -27,6 +27,7 @@ class GetSchemaTest(CiTestCase):
771 'cc_resizefs',
772 'cc_runcmd',
773 'cc_snap',
774+ 'cc_ubuntu_advantage',
775 'cc_zypper_add_repo'
776 ],
777 [subschema['id'] for subschema in schema['allOf']])

Subscribers

People subscribed via source and target branches