Merge ~chad.smith/cloud-init:cc-landscape-py3-config-fix into cloud-init:master

Proposed by Chad Smith
Status: Merged
Approved by: Scott Moser
Approved revision: f32d3748874bf947ba688d52ccda7095d5929515
Merged at revision: f831a874021f3d6d24cbe5639a176f416b5436a6
Proposed branch: ~chad.smith/cloud-init:cc-landscape-py3-config-fix
Merge into: cloud-init:master
Diff against target: 402 lines (+298/-24)
5 files modified
cloudinit/config/cc_landscape.py (+2/-2)
cloudinit/config/cc_puppet.py (+18/-15)
cloudinit/helpers.py (+7/-7)
tests/unittests/test_handler/test_handler_landscape.py (+129/-0)
tests/unittests/test_handler/test_handler_puppet.py (+142/-0)
Reviewer Review Type Date Requested Status
Scott Moser Approve
Server Team CI bot continuous-integration Approve
Review via email: mp+329087@code.launchpad.net

Description of the change

cc_landscape & cc_puppet: Fix six.StringIO use in writing configs

Both landscape and puppet modules had issues with the way they wrote /etc/landscape/client.conf or /etc/puppet/puppet.conf on either python3 or python2. This branch adds initial unit tests for both modules which will get better exercise under both python2 and python3.

The unit tests shed light on a few issues:
   - In the cc_landscape module py3 can't provide six.StringIO content to ConfigParser.write, so we need to ue six.BytesIO instead
   - In the cc_puppet module, python <= 2.7 doesn't support using six.StringIO as a context manager, so we drop the context manager fanciness and directly outputstream = StringIO() directly.
   - The docstring in cc_puppet is fixed to represent that the conf sub-key needs to contain valid puppet section names enclosing each key-value list.

LP: #1699282
LP: #1710932

To post a comment you must log in.
Revision history for this message
Ryan Harper (raharper) wrote :

Nice work. Couple of questions inline.

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

PASSED: Continuous integration, rev:cffa37df647e0822f0ecfae08efb442cc9ed6278
https://jenkins.ubuntu.com/server/job/cloud-init-ci/150/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: CentOS 6 & 7: Build & Test
    IN_PROGRESS: Declarative: Post Actions

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

review: Approve (continuous-integration)
8492ee9... by Chad Smith

drop unneeded path.exists assert and reformat default_landscape test conf file

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

comments addressed

Revision history for this message
Scott Moser (smoser) wrote :

Under
 https://code.launchpad.net/~mb-methot/cloud-init/+git/cloud-init/+merge/326017
I pointed to http://paste.ubuntu.com/25069502/
which showed why the fix there was insufficient.
if you've *not* covered what those unit tests cover, please add them.

over all, looks good, some comments inline.

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

i dont want to hold getting this in for a test re-work..
if you can show an example of wrap_and_call busted, just open a bug with that, and then lets fix this here.

Revision history for this message
Scott Moser (smoser) wrote :

s/fix this here/get this in as it is/

2870d72... by Chad Smith

add newline to end of stringify, use tmp_path in unit tests instead of os.path.join

a6ac3e4... by Chad Smith

drop nested mock.patch context managers in favor of wrap_and_call

f32d374... by Chad Smith

flakes8

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

Thanks for the patience on this. I figured out the issue I had with my calls to helpers.wrap_and_call resulted from relying on mock.patch(return_value) instead of mock.patch(new)

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

PASSED: Continuous integration, rev:f32d3748874bf947ba688d52ccda7095d5929515
https://jenkins.ubuntu.com/server/job/cloud-init-ci/185/
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/185/rebuild

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

thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/cc_landscape.py b/cloudinit/config/cc_landscape.py
2index 86b7138..8f9f1ab 100644
3--- a/cloudinit/config/cc_landscape.py
4+++ b/cloudinit/config/cc_landscape.py
5@@ -57,7 +57,7 @@ The following default client config is provided, but can be overridden::
6
7 import os
8
9-from six import StringIO
10+from six import BytesIO
11
12 from configobj import ConfigObj
13
14@@ -109,7 +109,7 @@ def handle(_name, cfg, cloud, log, _args):
15 ls_cloudcfg,
16 ]
17 merged = merge_together(merge_data)
18- contents = StringIO()
19+ contents = BytesIO()
20 merged.write(contents)
21
22 util.ensure_dir(os.path.dirname(LSC_CLIENT_CFG_FILE))
23diff --git a/cloudinit/config/cc_puppet.py b/cloudinit/config/cc_puppet.py
24index dc11561..28b1d56 100644
25--- a/cloudinit/config/cc_puppet.py
26+++ b/cloudinit/config/cc_puppet.py
27@@ -15,21 +15,23 @@ This module handles puppet installation and configuration. If the ``puppet``
28 key does not exist in global configuration, no action will be taken. If a
29 config entry for ``puppet`` is present, then by default the latest version of
30 puppet will be installed. If ``install`` is set to ``false``, puppet will not
31-be installed. However, this may result in an error if puppet is not already
32+be installed. However, this will result in an error if puppet is not already
33 present on the system. The version of puppet to be installed can be specified
34 under ``version``, and defaults to ``none``, which selects the latest version
35 in the repos. If the ``puppet`` config key exists in the config archive, this
36 module will attempt to start puppet even if no installation was performed.
37
38-Puppet configuration can be specified under the ``conf`` key. The configuration
39-is specified as a dictionary which is converted into ``<key>=<value>`` format
40-and appended to ``puppet.conf`` under the ``[puppetd]`` section. The
41+Puppet configuration can be specified under the ``conf`` key. The
42+configuration is specified as a dictionary containing high-level ``<section>``
43+keys and lists of ``<key>=<value>`` pairs within each section. Each section
44+name and ``<key>=<value>`` pair is written directly to ``puppet.conf``. As
45+such, section names should be one of: ``main``, ``master``, ``agent`` or
46+``user`` and keys should be valid puppet configuration options. The
47 ``certname`` key supports string substitutions for ``%i`` and ``%f``,
48 corresponding to the instance id and fqdn of the machine respectively.
49-If ``ca_cert`` is present under ``conf``, it will not be written to
50-``puppet.conf``, but instead will be used as the puppermaster certificate.
51-It should be specified in pem format as a multi-line string (using the ``|``
52-yaml notation).
53+If ``ca_cert`` is present, it will not be written to ``puppet.conf``, but
54+instead will be used as the puppermaster certificate. It should be specified
55+in pem format as a multi-line string (using the ``|`` yaml notation).
56
57 **Internal name:** ``cc_puppet``
58
59@@ -43,12 +45,13 @@ yaml notation).
60 install: <true/false>
61 version: <version>
62 conf:
63- server: "puppetmaster.example.org"
64- certname: "%i.%f"
65- ca_cert: |
66- -------BEGIN CERTIFICATE-------
67- <cert data>
68- -------END CERTIFICATE-------
69+ agent:
70+ server: "puppetmaster.example.org"
71+ certname: "%i.%f"
72+ ca_cert: |
73+ -------BEGIN CERTIFICATE-------
74+ <cert data>
75+ -------END CERTIFICATE-------
76 """
77
78 from six import StringIO
79@@ -127,7 +130,7 @@ def handle(name, cfg, cloud, log, _args):
80 util.write_file(PUPPET_SSL_CERT_PATH, cfg)
81 util.chownbyname(PUPPET_SSL_CERT_PATH, 'puppet', 'root')
82 else:
83- # Iterate throug the config items, we'll use ConfigParser.set
84+ # Iterate through the config items, we'll use ConfigParser.set
85 # to overwrite or create new items as needed
86 for (o, v) in cfg.items():
87 if o == 'certname':
88diff --git a/cloudinit/helpers.py b/cloudinit/helpers.py
89index f01021a..1979cd9 100644
90--- a/cloudinit/helpers.py
91+++ b/cloudinit/helpers.py
92@@ -13,7 +13,7 @@ from time import time
93 import contextlib
94 import os
95
96-import six
97+from six import StringIO
98 from six.moves.configparser import (
99 NoSectionError, NoOptionError, RawConfigParser)
100
101@@ -441,12 +441,12 @@ class DefaultingConfigParser(RawConfigParser):
102
103 def stringify(self, header=None):
104 contents = ''
105- with six.StringIO() as outputstream:
106- self.write(outputstream)
107- outputstream.flush()
108- contents = outputstream.getvalue()
109- if header:
110- contents = "\n".join([header, contents])
111+ outputstream = StringIO()
112+ self.write(outputstream)
113+ outputstream.flush()
114+ contents = outputstream.getvalue()
115+ if header:
116+ contents = '\n'.join([header, contents, ''])
117 return contents
118
119 # vi: ts=4 expandtab
120diff --git a/tests/unittests/test_handler/test_handler_landscape.py b/tests/unittests/test_handler/test_handler_landscape.py
121new file mode 100644
122index 0000000..7c247fa
123--- /dev/null
124+++ b/tests/unittests/test_handler/test_handler_landscape.py
125@@ -0,0 +1,129 @@
126+# This file is part of cloud-init. See LICENSE file for license information.
127+
128+from cloudinit.config import cc_landscape
129+from cloudinit.sources import DataSourceNone
130+from cloudinit import (distros, helpers, cloud, util)
131+from ..helpers import FilesystemMockingTestCase, mock, wrap_and_call
132+
133+from configobj import ConfigObj
134+import logging
135+
136+
137+LOG = logging.getLogger(__name__)
138+
139+
140+class TestLandscape(FilesystemMockingTestCase):
141+
142+ with_logs = True
143+
144+ def setUp(self):
145+ super(TestLandscape, self).setUp()
146+ self.new_root = self.tmp_dir()
147+ self.conf = self.tmp_path('client.conf', self.new_root)
148+ self.default_file = self.tmp_path('default_landscape', self.new_root)
149+
150+ def _get_cloud(self, distro):
151+ self.patchUtils(self.new_root)
152+ paths = helpers.Paths({'templates_dir': self.new_root})
153+ cls = distros.fetch(distro)
154+ mydist = cls(distro, {}, paths)
155+ myds = DataSourceNone.DataSourceNone({}, mydist, paths)
156+ return cloud.Cloud(myds, paths, {}, mydist, None)
157+
158+ def test_handler_skips_empty_landscape_cloudconfig(self):
159+ """Empty landscape cloud-config section does no work."""
160+ mycloud = self._get_cloud('ubuntu')
161+ mycloud.distro = mock.MagicMock()
162+ cfg = {'landscape': {}}
163+ cc_landscape.handle('notimportant', cfg, mycloud, LOG, None)
164+ self.assertFalse(mycloud.distro.install_packages.called)
165+
166+ def test_handler_error_on_invalid_landscape_type(self):
167+ """Raise an error when landscape configuraiton option is invalid."""
168+ mycloud = self._get_cloud('ubuntu')
169+ cfg = {'landscape': 'wrongtype'}
170+ with self.assertRaises(RuntimeError) as context_manager:
171+ cc_landscape.handle('notimportant', cfg, mycloud, LOG, None)
172+ self.assertIn(
173+ "'landscape' key existed in config, but not a dict",
174+ str(context_manager.exception))
175+
176+ @mock.patch('cloudinit.config.cc_landscape.util')
177+ def test_handler_restarts_landscape_client(self, m_util):
178+ """handler restarts lansdscape-client after install."""
179+ mycloud = self._get_cloud('ubuntu')
180+ cfg = {'landscape': {'client': {}}}
181+ wrap_and_call(
182+ 'cloudinit.config.cc_landscape',
183+ {'LSC_CLIENT_CFG_FILE': {'new': self.conf}},
184+ cc_landscape.handle, 'notimportant', cfg, mycloud, LOG, None)
185+ self.assertEqual(
186+ [mock.call(['service', 'landscape-client', 'restart'])],
187+ m_util.subp.call_args_list)
188+
189+ def test_handler_installs_client_and_creates_config_file(self):
190+ """Write landscape client.conf and install landscape-client."""
191+ mycloud = self._get_cloud('ubuntu')
192+ cfg = {'landscape': {'client': {}}}
193+ expected = {'client': {
194+ 'log_level': 'info',
195+ 'url': 'https://landscape.canonical.com/message-system',
196+ 'ping_url': 'http://landscape.canonical.com/ping',
197+ 'data_path': '/var/lib/landscape/client'}}
198+ mycloud.distro = mock.MagicMock()
199+ wrap_and_call(
200+ 'cloudinit.config.cc_landscape',
201+ {'LSC_CLIENT_CFG_FILE': {'new': self.conf},
202+ 'LS_DEFAULT_FILE': {'new': self.default_file}},
203+ cc_landscape.handle, 'notimportant', cfg, mycloud, LOG, None)
204+ self.assertEqual(
205+ [mock.call('landscape-client')],
206+ mycloud.distro.install_packages.call_args)
207+ self.assertEqual(expected, dict(ConfigObj(self.conf)))
208+ self.assertIn(
209+ 'Wrote landscape config file to {0}'.format(self.conf),
210+ self.logs.getvalue())
211+ default_content = util.load_file(self.default_file)
212+ self.assertEqual('RUN=1\n', default_content)
213+
214+ def test_handler_writes_merged_client_config_file_with_defaults(self):
215+ """Merge and write options from LSC_CLIENT_CFG_FILE with defaults."""
216+ # Write existing sparse client.conf file
217+ util.write_file(self.conf, '[client]\ncomputer_title = My PC\n')
218+ mycloud = self._get_cloud('ubuntu')
219+ cfg = {'landscape': {'client': {}}}
220+ expected = {'client': {
221+ 'log_level': 'info',
222+ 'url': 'https://landscape.canonical.com/message-system',
223+ 'ping_url': 'http://landscape.canonical.com/ping',
224+ 'data_path': '/var/lib/landscape/client',
225+ 'computer_title': 'My PC'}}
226+ wrap_and_call(
227+ 'cloudinit.config.cc_landscape',
228+ {'LSC_CLIENT_CFG_FILE': {'new': self.conf}},
229+ cc_landscape.handle, 'notimportant', cfg, mycloud, LOG, None)
230+ self.assertEqual(expected, dict(ConfigObj(self.conf)))
231+ self.assertIn(
232+ 'Wrote landscape config file to {0}'.format(self.conf),
233+ self.logs.getvalue())
234+
235+ def test_handler_writes_merged_provided_cloudconfig_with_defaults(self):
236+ """Merge and write options from cloud-config options with defaults."""
237+ # Write empty sparse client.conf file
238+ util.write_file(self.conf, '')
239+ mycloud = self._get_cloud('ubuntu')
240+ cfg = {'landscape': {'client': {'computer_title': 'My PC'}}}
241+ expected = {'client': {
242+ 'log_level': 'info',
243+ 'url': 'https://landscape.canonical.com/message-system',
244+ 'ping_url': 'http://landscape.canonical.com/ping',
245+ 'data_path': '/var/lib/landscape/client',
246+ 'computer_title': 'My PC'}}
247+ wrap_and_call(
248+ 'cloudinit.config.cc_landscape',
249+ {'LSC_CLIENT_CFG_FILE': {'new': self.conf}},
250+ cc_landscape.handle, 'notimportant', cfg, mycloud, LOG, None)
251+ self.assertEqual(expected, dict(ConfigObj(self.conf)))
252+ self.assertIn(
253+ 'Wrote landscape config file to {0}'.format(self.conf),
254+ self.logs.getvalue())
255diff --git a/tests/unittests/test_handler/test_handler_puppet.py b/tests/unittests/test_handler/test_handler_puppet.py
256new file mode 100644
257index 0000000..805c76b
258--- /dev/null
259+++ b/tests/unittests/test_handler/test_handler_puppet.py
260@@ -0,0 +1,142 @@
261+# This file is part of cloud-init. See LICENSE file for license information.
262+
263+from cloudinit.config import cc_puppet
264+from cloudinit.sources import DataSourceNone
265+from cloudinit import (distros, helpers, cloud, util)
266+from ..helpers import CiTestCase, mock
267+
268+import logging
269+
270+
271+LOG = logging.getLogger(__name__)
272+
273+
274+@mock.patch('cloudinit.config.cc_puppet.util')
275+@mock.patch('cloudinit.config.cc_puppet.os')
276+class TestAutostartPuppet(CiTestCase):
277+
278+ with_logs = True
279+
280+ def test_wb_autostart_puppet_updates_puppet_default(self, m_os, m_util):
281+ """Update /etc/default/puppet to autostart if it exists."""
282+
283+ def _fake_exists(path):
284+ return path == '/etc/default/puppet'
285+
286+ m_os.path.exists.side_effect = _fake_exists
287+ cc_puppet._autostart_puppet(LOG)
288+ self.assertEqual(
289+ [mock.call(['sed', '-i', '-e', 's/^START=.*/START=yes/',
290+ '/etc/default/puppet'], capture=False)],
291+ m_util.subp.call_args_list)
292+
293+ def test_wb_autostart_pupppet_enables_puppet_systemctl(self, m_os, m_util):
294+ """If systemctl is present, enable puppet via systemctl."""
295+
296+ def _fake_exists(path):
297+ return path == '/bin/systemctl'
298+
299+ m_os.path.exists.side_effect = _fake_exists
300+ cc_puppet._autostart_puppet(LOG)
301+ expected_calls = [mock.call(
302+ ['/bin/systemctl', 'enable', 'puppet.service'], capture=False)]
303+ self.assertEqual(expected_calls, m_util.subp.call_args_list)
304+
305+ def test_wb_autostart_pupppet_enables_puppet_chkconfig(self, m_os, m_util):
306+ """If chkconfig is present, enable puppet via checkcfg."""
307+
308+ def _fake_exists(path):
309+ return path == '/sbin/chkconfig'
310+
311+ m_os.path.exists.side_effect = _fake_exists
312+ cc_puppet._autostart_puppet(LOG)
313+ expected_calls = [mock.call(
314+ ['/sbin/chkconfig', 'puppet', 'on'], capture=False)]
315+ self.assertEqual(expected_calls, m_util.subp.call_args_list)
316+
317+
318+@mock.patch('cloudinit.config.cc_puppet._autostart_puppet')
319+class TestPuppetHandle(CiTestCase):
320+
321+ with_logs = True
322+
323+ def setUp(self):
324+ super(TestPuppetHandle, self).setUp()
325+ self.new_root = self.tmp_dir()
326+ self.conf = self.tmp_path('puppet.conf')
327+
328+ def _get_cloud(self, distro):
329+ paths = helpers.Paths({'templates_dir': self.new_root})
330+ cls = distros.fetch(distro)
331+ mydist = cls(distro, {}, paths)
332+ myds = DataSourceNone.DataSourceNone({}, mydist, paths)
333+ return cloud.Cloud(myds, paths, {}, mydist, None)
334+
335+ def test_handler_skips_missing_puppet_key_in_cloudconfig(self, m_auto):
336+ """Cloud-config containing no 'puppet' key is skipped."""
337+ mycloud = self._get_cloud('ubuntu')
338+ cfg = {}
339+ cc_puppet.handle('notimportant', cfg, mycloud, LOG, None)
340+ self.assertIn(
341+ "no 'puppet' configuration found", self.logs.getvalue())
342+ self.assertEqual(0, m_auto.call_count)
343+
344+ @mock.patch('cloudinit.config.cc_puppet.util.subp')
345+ def test_handler_puppet_config_starts_puppet_service(self, m_subp, m_auto):
346+ """Cloud-config 'puppet' configuration starts puppet."""
347+ mycloud = self._get_cloud('ubuntu')
348+ cfg = {'puppet': {'install': False}}
349+ cc_puppet.handle('notimportant', cfg, mycloud, LOG, None)
350+ self.assertEqual(1, m_auto.call_count)
351+ self.assertEqual(
352+ [mock.call(['service', 'puppet', 'start'], capture=False)],
353+ m_subp.call_args_list)
354+
355+ @mock.patch('cloudinit.config.cc_puppet.util.subp')
356+ def test_handler_empty_puppet_config_installs_puppet(self, m_subp, m_auto):
357+ """Cloud-config empty 'puppet' configuration installs latest puppet."""
358+ mycloud = self._get_cloud('ubuntu')
359+ mycloud.distro = mock.MagicMock()
360+ cfg = {'puppet': {}}
361+ cc_puppet.handle('notimportant', cfg, mycloud, LOG, None)
362+ self.assertEqual(
363+ [mock.call(('puppet', None))],
364+ mycloud.distro.install_packages.call_args_list)
365+
366+ @mock.patch('cloudinit.config.cc_puppet.util.subp')
367+ def test_handler_puppet_config_installs_puppet_on_true(self, m_subp, _):
368+ """Cloud-config with 'puppet' key installs when 'install' is True."""
369+ mycloud = self._get_cloud('ubuntu')
370+ mycloud.distro = mock.MagicMock()
371+ cfg = {'puppet': {'install': True}}
372+ cc_puppet.handle('notimportant', cfg, mycloud, LOG, None)
373+ self.assertEqual(
374+ [mock.call(('puppet', None))],
375+ mycloud.distro.install_packages.call_args_list)
376+
377+ @mock.patch('cloudinit.config.cc_puppet.util.subp')
378+ def test_handler_puppet_config_installs_puppet_version(self, m_subp, _):
379+ """Cloud-config 'puppet' configuration can specify a version."""
380+ mycloud = self._get_cloud('ubuntu')
381+ mycloud.distro = mock.MagicMock()
382+ cfg = {'puppet': {'version': '3.8'}}
383+ cc_puppet.handle('notimportant', cfg, mycloud, LOG, None)
384+ self.assertEqual(
385+ [mock.call(('puppet', '3.8'))],
386+ mycloud.distro.install_packages.call_args_list)
387+
388+ @mock.patch('cloudinit.config.cc_puppet.util.subp')
389+ def test_handler_puppet_config_updates_puppet_conf(self, m_subp, m_auto):
390+ """When 'conf' is provided update values in PUPPET_CONF_PATH."""
391+ mycloud = self._get_cloud('ubuntu')
392+ cfg = {
393+ 'puppet': {
394+ 'conf': {'agent': {'server': 'puppetmaster.example.org'}}}}
395+ util.write_file(self.conf, '[agent]\nserver = origpuppet\nother = 3')
396+ puppet_conf_path = 'cloudinit.config.cc_puppet.PUPPET_CONF_PATH'
397+ mycloud.distro = mock.MagicMock()
398+ with mock.patch(puppet_conf_path, self.conf):
399+ cc_puppet.handle('notimportant', cfg, mycloud, LOG, None)
400+ content = util.load_file(self.conf)
401+ expected = '[agent]\nserver = puppetmaster.example.org\nother = 3\n\n'
402+ self.assertEqual(expected, content)

Subscribers

People subscribed via source and target branches