Merge ~chad.smith/cloud-init:ubuntu/devel into cloud-init:ubuntu/devel

Proposed by Chad Smith
Status: Merged
Merged at revision: a6262577f56d32fb6005a55f9022309c5dc7dce5
Proposed branch: ~chad.smith/cloud-init:ubuntu/devel
Merge into: cloud-init:ubuntu/devel
Diff against target: 1999 lines (+765/-204)
46 files modified
.pylintrc (+11/-1)
cloudinit/cloud.py (+3/-2)
cloudinit/cmd/main.py (+29/-6)
cloudinit/cmd/tests/test_main.py (+161/-0)
cloudinit/config/cc_keys_to_console.py (+1/-3)
cloudinit/config/cc_runcmd.py (+4/-2)
cloudinit/config/cc_salt_minion.py (+59/-23)
cloudinit/config/cc_set_hostname.py (+35/-6)
cloudinit/config/cc_ssh_authkey_fingerprints.py (+4/-5)
cloudinit/distros/arch.py (+1/-4)
cloudinit/distros/freebsd.py (+6/-0)
cloudinit/distros/opensuse.py (+2/-3)
cloudinit/sources/DataSourceAzure.py (+2/-0)
cloudinit/sources/DataSourceOpenNebula.py (+1/-4)
cloudinit/sources/__init__.py (+17/-4)
cloudinit/sources/tests/test_init.py (+69/-1)
cloudinit/stages.py (+1/-2)
cloudinit/tests/helpers.py (+13/-0)
cloudinit/tests/test_util.py (+97/-0)
cloudinit/url_helper.py (+2/-2)
cloudinit/util.py (+32/-14)
config/cloud.cfg.tmpl (+1/-1)
debian/changelog (+22/-0)
doc/rtd/topics/capabilities.rst (+8/-6)
doc/rtd/topics/debugging.rst (+31/-26)
doc/rtd/topics/network-config.rst (+2/-2)
doc/rtd/topics/tests.rst (+10/-10)
tests/cloud_tests/bddeb.py (+1/-1)
tests/cloud_tests/platforms/ec2/__init__.py (+0/-0)
tests/cloud_tests/platforms/lxd/__init__.py (+0/-0)
tests/cloud_tests/platforms/lxd/platform.py (+0/-4)
tests/cloud_tests/platforms/nocloudkvm/__init__.py (+0/-0)
tests/cloud_tests/platforms/nocloudkvm/instance.py (+1/-1)
tests/cloud_tests/platforms/nocloudkvm/platform.py (+0/-4)
tests/cloud_tests/platforms/platforms.py (+12/-2)
tests/cloud_tests/testcases/modules/salt_minion.py (+5/-0)
tests/cloud_tests/testcases/modules/salt_minion.yaml (+4/-1)
tests/cloud_tests/util.py (+5/-1)
tests/unittests/test_datasource/test_azure.py (+15/-0)
tests/unittests/test_handler/test_handler_bootcmd.py (+7/-12)
tests/unittests/test_handler/test_handler_ntp.py (+6/-12)
tests/unittests/test_handler/test_handler_resizefs.py (+3/-11)
tests/unittests/test_handler/test_handler_runcmd.py (+4/-10)
tests/unittests/test_handler/test_handler_set_hostname.py (+53/-4)
tests/unittests/test_handler/test_schema.py (+7/-14)
tests/unittests/test_util.py (+18/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Scott Moser Pending
Review via email: mp+341482@code.launchpad.net

Description of the change

Sync tip of master for publish in Bionic.

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

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

review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.pylintrc b/.pylintrc
2index 05a086d..0bdfa59 100644
3--- a/.pylintrc
4+++ b/.pylintrc
5@@ -46,7 +46,17 @@ reports=no
6 # (useful for modules/projects where namespaces are manipulated during runtime
7 # and thus existing member attributes cannot be deduced by static analysis. It
8 # supports qualified module names, as well as Unix pattern matching.
9-ignored-modules=six.moves,pkg_resources,httplib,http.client,paramiko,simplestreams
10+ignored-modules=
11+ http.client,
12+ httplib,
13+ pkg_resources,
14+ six.moves,
15+ # cloud_tests requirements.
16+ boto3,
17+ botocore,
18+ paramiko,
19+ pylxd,
20+ simplestreams
21
22 # List of class names for which member attributes should not be checked (useful
23 # for classes with dynamically set attributes). This supports the use of
24diff --git a/cloudinit/cloud.py b/cloudinit/cloud.py
25index ba61678..6d12c43 100644
26--- a/cloudinit/cloud.py
27+++ b/cloudinit/cloud.py
28@@ -78,8 +78,9 @@ class Cloud(object):
29 def get_locale(self):
30 return self.datasource.get_locale()
31
32- def get_hostname(self, fqdn=False):
33- return self.datasource.get_hostname(fqdn=fqdn)
34+ def get_hostname(self, fqdn=False, metadata_only=False):
35+ return self.datasource.get_hostname(
36+ fqdn=fqdn, metadata_only=metadata_only)
37
38 def device_name_to_device(self, name):
39 return self.datasource.device_name_to_device(name)
40diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
41index d2f1b77..3f2dbb9 100644
42--- a/cloudinit/cmd/main.py
43+++ b/cloudinit/cmd/main.py
44@@ -40,6 +40,7 @@ from cloudinit.settings import (PER_INSTANCE, PER_ALWAYS, PER_ONCE,
45
46 from cloudinit import atomic_helper
47
48+from cloudinit.config import cc_set_hostname
49 from cloudinit.dhclient_hook import LogDhclient
50
51
52@@ -215,12 +216,10 @@ def main_init(name, args):
53 if args.local:
54 deps = [sources.DEP_FILESYSTEM]
55
56- early_logs = []
57- early_logs.append(
58- attempt_cmdline_url(
59- path=os.path.join("%s.d" % CLOUD_CONFIG,
60- "91_kernel_cmdline_url.cfg"),
61- network=not args.local))
62+ early_logs = [attempt_cmdline_url(
63+ path=os.path.join("%s.d" % CLOUD_CONFIG,
64+ "91_kernel_cmdline_url.cfg"),
65+ network=not args.local)]
66
67 # Cloud-init 'init' stage is broken up into the following sub-stages
68 # 1. Ensure that the init object fetches its config without errors
69@@ -354,6 +353,11 @@ def main_init(name, args):
70 LOG.debug("[%s] %s will now be targeting instance id: %s. new=%s",
71 mode, name, iid, init.is_new_instance())
72
73+ if mode == sources.DSMODE_LOCAL:
74+ # Before network comes up, set any configured hostname to allow
75+ # dhcp clients to advertize this hostname to any DDNS services
76+ # LP: #1746455.
77+ _maybe_set_hostname(init, stage='local', retry_stage='network')
78 init.apply_network_config(bring_up=bool(mode != sources.DSMODE_LOCAL))
79
80 if mode == sources.DSMODE_LOCAL:
81@@ -370,6 +374,7 @@ def main_init(name, args):
82 init.setup_datasource()
83 # update fully realizes user-data (pulling in #include if necessary)
84 init.update()
85+ _maybe_set_hostname(init, stage='init-net', retry_stage='modules:config')
86 # Stage 7
87 try:
88 # Attempt to consume the data per instance.
89@@ -683,6 +688,24 @@ def status_wrapper(name, args, data_d=None, link_d=None):
90 return len(v1[mode]['errors'])
91
92
93+def _maybe_set_hostname(init, stage, retry_stage):
94+ """Call set-hostname if metadata, vendordata or userdata provides it.
95+
96+ @param stage: String representing current stage in which we are running.
97+ @param retry_stage: String represented logs upon error setting hostname.
98+ """
99+ cloud = init.cloudify()
100+ (hostname, _fqdn) = util.get_hostname_fqdn(
101+ init.cfg, cloud, metadata_only=True)
102+ if hostname: # meta-data or user-data hostname content
103+ try:
104+ cc_set_hostname.handle('set-hostname', init.cfg, cloud, LOG, None)
105+ except cc_set_hostname.SetHostnameError as e:
106+ LOG.debug(
107+ 'Failed setting hostname in %s stage. Will'
108+ ' retry in %s stage. Error: %s.', stage, retry_stage, str(e))
109+
110+
111 def main_features(name, args):
112 sys.stdout.write('\n'.join(sorted(version.FEATURES)) + '\n')
113
114diff --git a/cloudinit/cmd/tests/test_main.py b/cloudinit/cmd/tests/test_main.py
115new file mode 100644
116index 0000000..dbe421c
117--- /dev/null
118+++ b/cloudinit/cmd/tests/test_main.py
119@@ -0,0 +1,161 @@
120+# This file is part of cloud-init. See LICENSE file for license information.
121+
122+from collections import namedtuple
123+import copy
124+import os
125+from six import StringIO
126+
127+from cloudinit.cmd import main
128+from cloudinit.util import (
129+ ensure_dir, load_file, write_file, yaml_dumps)
130+from cloudinit.tests.helpers import (
131+ FilesystemMockingTestCase, wrap_and_call)
132+
133+mypaths = namedtuple('MyPaths', 'run_dir')
134+myargs = namedtuple('MyArgs', 'debug files force local reporter subcommand')
135+
136+
137+class TestMain(FilesystemMockingTestCase):
138+
139+ with_logs = True
140+
141+ def setUp(self):
142+ super(TestMain, self).setUp()
143+ self.new_root = self.tmp_dir()
144+ self.cloud_dir = self.tmp_path('var/lib/cloud/', dir=self.new_root)
145+ os.makedirs(self.cloud_dir)
146+ self.replicateTestRoot('simple_ubuntu', self.new_root)
147+ self.cfg = {
148+ 'datasource_list': ['None'],
149+ 'runcmd': ['ls /etc'], # test ALL_DISTROS
150+ 'system_info': {'paths': {'cloud_dir': self.cloud_dir,
151+ 'run_dir': self.new_root}},
152+ 'write_files': [
153+ {
154+ 'path': '/etc/blah.ini',
155+ 'content': 'blah',
156+ 'permissions': 0o755,
157+ },
158+ ],
159+ 'cloud_init_modules': ['write-files', 'runcmd'],
160+ }
161+ cloud_cfg = yaml_dumps(self.cfg)
162+ ensure_dir(os.path.join(self.new_root, 'etc', 'cloud'))
163+ self.cloud_cfg_file = os.path.join(
164+ self.new_root, 'etc', 'cloud', 'cloud.cfg')
165+ write_file(self.cloud_cfg_file, cloud_cfg)
166+ self.patchOS(self.new_root)
167+ self.patchUtils(self.new_root)
168+ self.stderr = StringIO()
169+ self.patchStdoutAndStderr(stderr=self.stderr)
170+
171+ def test_main_init_run_net_stops_on_file_no_net(self):
172+ """When no-net file is present, main_init does not process modules."""
173+ stop_file = os.path.join(self.cloud_dir, 'data', 'no-net') # stop file
174+ write_file(stop_file, '')
175+ cmdargs = myargs(
176+ debug=False, files=None, force=False, local=False, reporter=None,
177+ subcommand='init')
178+ (item1, item2) = wrap_and_call(
179+ 'cloudinit.cmd.main',
180+ {'util.close_stdin': True,
181+ 'netinfo.debug_info': 'my net debug info',
182+ 'util.fixup_output': ('outfmt', 'errfmt')},
183+ main.main_init, 'init', cmdargs)
184+ # We should not run write_files module
185+ self.assertFalse(
186+ os.path.exists(os.path.join(self.new_root, 'etc/blah.ini')),
187+ 'Unexpected run of write_files module produced blah.ini')
188+ self.assertEqual([], item2)
189+ # Instancify is called
190+ instance_id_path = 'var/lib/cloud/data/instance-id'
191+ self.assertFalse(
192+ os.path.exists(os.path.join(self.new_root, instance_id_path)),
193+ 'Unexpected call to datasource.instancify produced instance-id')
194+ expected_logs = [
195+ "Exiting. stop file ['{stop_file}'] existed\n".format(
196+ stop_file=stop_file),
197+ 'my net debug info' # netinfo.debug_info
198+ ]
199+ for log in expected_logs:
200+ self.assertIn(log, self.stderr.getvalue())
201+
202+ def test_main_init_run_net_runs_modules(self):
203+ """Modules like write_files are run in 'net' mode."""
204+ cmdargs = myargs(
205+ debug=False, files=None, force=False, local=False, reporter=None,
206+ subcommand='init')
207+ (item1, item2) = wrap_and_call(
208+ 'cloudinit.cmd.main',
209+ {'util.close_stdin': True,
210+ 'netinfo.debug_info': 'my net debug info',
211+ 'util.fixup_output': ('outfmt', 'errfmt')},
212+ main.main_init, 'init', cmdargs)
213+ self.assertEqual([], item2)
214+ # Instancify is called
215+ instance_id_path = 'var/lib/cloud/data/instance-id'
216+ self.assertEqual(
217+ 'iid-datasource-none\n',
218+ os.path.join(load_file(
219+ os.path.join(self.new_root, instance_id_path))))
220+ # modules are run (including write_files)
221+ self.assertEqual(
222+ 'blah', load_file(os.path.join(self.new_root, 'etc/blah.ini')))
223+ expected_logs = [
224+ 'network config is disabled by fallback', # apply_network_config
225+ 'my net debug info', # netinfo.debug_info
226+ 'no previous run detected'
227+ ]
228+ for log in expected_logs:
229+ self.assertIn(log, self.stderr.getvalue())
230+
231+ def test_main_init_run_net_calls_set_hostname_when_metadata_present(self):
232+ """When local-hostname metadata is present, call cc_set_hostname."""
233+ self.cfg['datasource'] = {
234+ 'None': {'metadata': {'local-hostname': 'md-hostname'}}}
235+ cloud_cfg = yaml_dumps(self.cfg)
236+ write_file(self.cloud_cfg_file, cloud_cfg)
237+ cmdargs = myargs(
238+ debug=False, files=None, force=False, local=False, reporter=None,
239+ subcommand='init')
240+
241+ def set_hostname(name, cfg, cloud, log, args):
242+ self.assertEqual('set-hostname', name)
243+ updated_cfg = copy.deepcopy(self.cfg)
244+ updated_cfg.update(
245+ {'def_log_file': '/var/log/cloud-init.log',
246+ 'log_cfgs': [],
247+ 'syslog_fix_perms': ['syslog:adm', 'root:adm', 'root:wheel'],
248+ 'vendor_data': {'enabled': True, 'prefix': []}})
249+ updated_cfg.pop('system_info')
250+
251+ self.assertEqual(updated_cfg, cfg)
252+ self.assertEqual(main.LOG, log)
253+ self.assertIsNone(args)
254+
255+ (item1, item2) = wrap_and_call(
256+ 'cloudinit.cmd.main',
257+ {'util.close_stdin': True,
258+ 'netinfo.debug_info': 'my net debug info',
259+ 'cc_set_hostname.handle': {'side_effect': set_hostname},
260+ 'util.fixup_output': ('outfmt', 'errfmt')},
261+ main.main_init, 'init', cmdargs)
262+ self.assertEqual([], item2)
263+ # Instancify is called
264+ instance_id_path = 'var/lib/cloud/data/instance-id'
265+ self.assertEqual(
266+ 'iid-datasource-none\n',
267+ os.path.join(load_file(
268+ os.path.join(self.new_root, instance_id_path))))
269+ # modules are run (including write_files)
270+ self.assertEqual(
271+ 'blah', load_file(os.path.join(self.new_root, 'etc/blah.ini')))
272+ expected_logs = [
273+ 'network config is disabled by fallback', # apply_network_config
274+ 'my net debug info', # netinfo.debug_info
275+ 'no previous run detected'
276+ ]
277+ for log in expected_logs:
278+ self.assertIn(log, self.stderr.getvalue())
279+
280+# vi: ts=4 expandtab
281diff --git a/cloudinit/config/cc_keys_to_console.py b/cloudinit/config/cc_keys_to_console.py
282index efedd4a..aff4010 100644
283--- a/cloudinit/config/cc_keys_to_console.py
284+++ b/cloudinit/config/cc_keys_to_console.py
285@@ -63,9 +63,7 @@ def handle(name, cfg, cloud, log, _args):
286 ["ssh-dss"])
287
288 try:
289- cmd = [helper_path]
290- cmd.append(','.join(fp_blacklist))
291- cmd.append(','.join(key_blacklist))
292+ cmd = [helper_path, ','.join(fp_blacklist), ','.join(key_blacklist)]
293 (stdout, _stderr) = util.subp(cmd)
294 util.multi_log("%s\n" % (stdout.strip()),
295 stderr=False, console=True)
296diff --git a/cloudinit/config/cc_runcmd.py b/cloudinit/config/cc_runcmd.py
297index 449872f..539cbd5 100644
298--- a/cloudinit/config/cc_runcmd.py
299+++ b/cloudinit/config/cc_runcmd.py
300@@ -39,8 +39,10 @@ schema = {
301 using ``sh``.
302
303 .. note::
304- all commands must be proper yaml, so you have to quote any characters
305- yaml would eat (':' can be problematic)"""),
306+
307+ all commands must be proper yaml, so you have to quote any characters
308+ yaml would eat (':' can be problematic)
309+ """),
310 'distros': distros,
311 'examples': [dedent("""\
312 runcmd:
313diff --git a/cloudinit/config/cc_salt_minion.py b/cloudinit/config/cc_salt_minion.py
314index 5112a34..d6a21d7 100644
315--- a/cloudinit/config/cc_salt_minion.py
316+++ b/cloudinit/config/cc_salt_minion.py
317@@ -12,7 +12,9 @@ key is present in the config parts, then salt minion will be installed and
318 started. Configuration for salt minion can be specified in the ``conf`` key
319 under ``salt_minion``. Any conf values present there will be assigned in
320 ``/etc/salt/minion``. The public and private keys to use for salt minion can be
321-specified with ``public_key`` and ``private_key`` respectively.
322+specified with ``public_key`` and ``private_key`` respectively. Optionally if
323+you have a custom package name, service name or config directory you can
324+specify them with ``pkg_name``, ``service_name`` and ``config_dir``.
325
326 **Internal name:** ``cc_salt_minion``
327
328@@ -23,6 +25,9 @@ specified with ``public_key`` and ``private_key`` respectively.
329 **Config keys**::
330
331 salt_minion:
332+ pkg_name: 'salt-minion'
333+ service_name: 'salt-minion'
334+ config_dir: '/etc/salt'
335 conf:
336 master: salt.example.com
337 grains:
338@@ -42,7 +47,34 @@ import os
339
340 from cloudinit import util
341
342-# Note: see http://saltstack.org/topics/installation/
343+# Note: see https://docs.saltstack.com/en/latest/topics/installation/
344+# Note: see https://docs.saltstack.com/en/latest/ref/configuration/
345+
346+
347+class SaltConstants(object):
348+ """
349+ defines default distribution specific salt variables
350+ """
351+ def __init__(self, cfg):
352+
353+ # constants tailored for FreeBSD
354+ if util.is_FreeBSD():
355+ self.pkg_name = 'py27-salt'
356+ self.srv_name = 'salt_minion'
357+ self.conf_dir = '/usr/local/etc/salt'
358+ # constants for any other OS
359+ else:
360+ self.pkg_name = 'salt-minion'
361+ self.srv_name = 'salt-minion'
362+ self.conf_dir = '/etc/salt'
363+
364+ # if there are constants given in cloud config use those
365+ self.pkg_name = util.get_cfg_option_str(cfg, 'pkg_name',
366+ self.pkg_name)
367+ self.conf_dir = util.get_cfg_option_str(cfg, 'config_dir',
368+ self.conf_dir)
369+ self.srv_name = util.get_cfg_option_str(cfg, 'service_name',
370+ self.srv_name)
371
372
373 def handle(name, cfg, cloud, log, _args):
374@@ -52,45 +84,49 @@ def handle(name, cfg, cloud, log, _args):
375 " no 'salt_minion' key in configuration"), name)
376 return
377
378- salt_cfg = cfg['salt_minion']
379+ s_cfg = cfg['salt_minion']
380+ const = SaltConstants(cfg=s_cfg)
381
382 # Start by installing the salt package ...
383- cloud.distro.install_packages(('salt-minion',))
384+ cloud.distro.install_packages(const.pkg_name)
385
386 # Ensure we can configure files at the right dir
387- config_dir = salt_cfg.get("config_dir", '/etc/salt')
388- util.ensure_dir(config_dir)
389+ util.ensure_dir(const.conf_dir)
390
391 # ... and then update the salt configuration
392- if 'conf' in salt_cfg:
393- # Add all sections from the conf object to /etc/salt/minion
394- minion_config = os.path.join(config_dir, 'minion')
395- minion_data = util.yaml_dumps(salt_cfg.get('conf'))
396+ if 'conf' in s_cfg:
397+ # Add all sections from the conf object to minion config file
398+ minion_config = os.path.join(const.conf_dir, 'minion')
399+ minion_data = util.yaml_dumps(s_cfg.get('conf'))
400 util.write_file(minion_config, minion_data)
401
402- if 'grains' in salt_cfg:
403+ if 'grains' in s_cfg:
404 # add grains to /etc/salt/grains
405- grains_config = os.path.join(config_dir, 'grains')
406- grains_data = util.yaml_dumps(salt_cfg.get('grains'))
407+ grains_config = os.path.join(const.conf_dir, 'grains')
408+ grains_data = util.yaml_dumps(s_cfg.get('grains'))
409 util.write_file(grains_config, grains_data)
410
411 # ... copy the key pair if specified
412- if 'public_key' in salt_cfg and 'private_key' in salt_cfg:
413- if os.path.isdir("/etc/salt/pki/minion"):
414- pki_dir_default = "/etc/salt/pki/minion"
415- else:
416- pki_dir_default = "/etc/salt/pki"
417+ if 'public_key' in s_cfg and 'private_key' in s_cfg:
418+ pki_dir_default = os.path.join(const.conf_dir, "pki/minion")
419+ if not os.path.isdir(pki_dir_default):
420+ pki_dir_default = os.path.join(const.conf_dir, "pki")
421
422- pki_dir = salt_cfg.get('pki_dir', pki_dir_default)
423+ pki_dir = s_cfg.get('pki_dir', pki_dir_default)
424 with util.umask(0o77):
425 util.ensure_dir(pki_dir)
426 pub_name = os.path.join(pki_dir, 'minion.pub')
427 pem_name = os.path.join(pki_dir, 'minion.pem')
428- util.write_file(pub_name, salt_cfg['public_key'])
429- util.write_file(pem_name, salt_cfg['private_key'])
430+ util.write_file(pub_name, s_cfg['public_key'])
431+ util.write_file(pem_name, s_cfg['private_key'])
432+
433+ # we need to have the salt minion service enabled in rc in order to be
434+ # able to start the service. this does only apply on FreeBSD servers.
435+ if cloud.distro.osfamily == 'freebsd':
436+ cloud.distro.updatercconf('salt_minion_enable', 'YES')
437
438- # restart salt-minion. 'service' will start even if not started. if it
439+ # restart salt-minion. 'service' will start even if not started. if it
440 # was started, it needs to be restarted for config change.
441- util.subp(['service', 'salt-minion', 'restart'], capture=False)
442+ util.subp(['service', const.srv_name, 'restart'], capture=False)
443
444 # vi: ts=4 expandtab
445diff --git a/cloudinit/config/cc_set_hostname.py b/cloudinit/config/cc_set_hostname.py
446index aa3dfe5..3d2b2da 100644
447--- a/cloudinit/config/cc_set_hostname.py
448+++ b/cloudinit/config/cc_set_hostname.py
449@@ -32,22 +32,51 @@ will be used.
450 hostname: <fqdn/hostname>
451 """
452
453+import os
454+
455+
456+from cloudinit.atomic_helper import write_json
457 from cloudinit import util
458
459
460+class SetHostnameError(Exception):
461+ """Raised when the distro runs into an exception when setting hostname.
462+
463+ This may happen if we attempt to set the hostname early in cloud-init's
464+ init-local timeframe as certain services may not be running yet.
465+ """
466+ pass
467+
468+
469 def handle(name, cfg, cloud, log, _args):
470 if util.get_cfg_option_bool(cfg, "preserve_hostname", False):
471 log.debug(("Configuration option 'preserve_hostname' is set,"
472 " not setting the hostname in module %s"), name)
473 return
474-
475 (hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
476+ # Check for previous successful invocation of set-hostname
477+
478+ # set-hostname artifact file accounts for both hostname and fqdn
479+ # deltas. As such, it's format is different than cc_update_hostname's
480+ # previous-hostname file which only contains the base hostname.
481+ # TODO consolidate previous-hostname and set-hostname artifact files and
482+ # distro._read_hostname implementation so we only validate one artifact.
483+ prev_fn = os.path.join(cloud.get_cpath('data'), "set-hostname")
484+ prev_hostname = {}
485+ if os.path.exists(prev_fn):
486+ prev_hostname = util.load_json(util.load_file(prev_fn))
487+ hostname_changed = (hostname != prev_hostname.get('hostname') or
488+ fqdn != prev_hostname.get('fqdn'))
489+ if not hostname_changed:
490+ log.debug('No hostname changes. Skipping set-hostname')
491+ return
492+ log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
493 try:
494- log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
495 cloud.distro.set_hostname(hostname, fqdn)
496- except Exception:
497- util.logexc(log, "Failed to set the hostname to %s (%s)", fqdn,
498- hostname)
499- raise
500+ except Exception as e:
501+ msg = "Failed to set the hostname to %s (%s)" % (fqdn, hostname)
502+ util.logexc(log, msg)
503+ raise SetHostnameError("%s: %s" % (msg, e))
504+ write_json(prev_fn, {'hostname': hostname, 'fqdn': fqdn})
505
506 # vi: ts=4 expandtab
507diff --git a/cloudinit/config/cc_ssh_authkey_fingerprints.py b/cloudinit/config/cc_ssh_authkey_fingerprints.py
508index 35d8c57..98b0e66 100755
509--- a/cloudinit/config/cc_ssh_authkey_fingerprints.py
510+++ b/cloudinit/config/cc_ssh_authkey_fingerprints.py
511@@ -77,11 +77,10 @@ def _pprint_key_entries(user, key_fn, key_entries, hash_meth='md5',
512 tbl = SimpleTable(tbl_fields)
513 for entry in key_entries:
514 if _is_printable_key(entry):
515- row = []
516- row.append(entry.keytype or '-')
517- row.append(_gen_fingerprint(entry.base64, hash_meth) or '-')
518- row.append(entry.options or '-')
519- row.append(entry.comment or '-')
520+ row = [entry.keytype or '-',
521+ _gen_fingerprint(entry.base64, hash_meth) or '-',
522+ entry.options or '-',
523+ entry.comment or '-']
524 tbl.add_row(row)
525 authtbl_s = tbl.get_string()
526 authtbl_lines = authtbl_s.splitlines()
527diff --git a/cloudinit/distros/arch.py b/cloudinit/distros/arch.py
528index f87a343..b814c8b 100644
529--- a/cloudinit/distros/arch.py
530+++ b/cloudinit/distros/arch.py
531@@ -129,11 +129,8 @@ class Distro(distros.Distro):
532 if pkgs is None:
533 pkgs = []
534
535- cmd = ['pacman']
536+ cmd = ['pacman', "-Sy", "--quiet", "--noconfirm"]
537 # Redirect output
538- cmd.append("-Sy")
539- cmd.append("--quiet")
540- cmd.append("--noconfirm")
541
542 if args and isinstance(args, str):
543 cmd.append(args)
544diff --git a/cloudinit/distros/freebsd.py b/cloudinit/distros/freebsd.py
545index aa468bc..754d3df 100644
546--- a/cloudinit/distros/freebsd.py
547+++ b/cloudinit/distros/freebsd.py
548@@ -132,6 +132,12 @@ class Distro(distros.Distro):
549 LOG.debug("Using network interface %s", bsddev)
550 return bsddev
551
552+ def _select_hostname(self, hostname, fqdn):
553+ # Should be FQDN if available. See rc.conf(5) in FreeBSD
554+ if fqdn:
555+ return fqdn
556+ return hostname
557+
558 def _read_system_hostname(self):
559 sys_hostname = self._read_hostname(filename=None)
560 return ('rc.conf', sys_hostname)
561diff --git a/cloudinit/distros/opensuse.py b/cloudinit/distros/opensuse.py
562index a219e9f..162dfa0 100644
563--- a/cloudinit/distros/opensuse.py
564+++ b/cloudinit/distros/opensuse.py
565@@ -67,11 +67,10 @@ class Distro(distros.Distro):
566 if pkgs is None:
567 pkgs = []
568
569- cmd = ['zypper']
570 # No user interaction possible, enable non-interactive mode
571- cmd.append('--non-interactive')
572+ cmd = ['zypper', '--non-interactive']
573
574- # Comand is the operation, such as install
575+ # Command is the operation, such as install
576 if command == 'upgrade':
577 command = 'update'
578 cmd.append(command)
579diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
580index 4bcbf3a..0bb7fad 100644
581--- a/cloudinit/sources/DataSourceAzure.py
582+++ b/cloudinit/sources/DataSourceAzure.py
583@@ -223,6 +223,8 @@ DEF_PASSWD_REDACTION = 'REDACTED'
584
585
586 def get_hostname(hostname_command='hostname'):
587+ if not isinstance(hostname_command, (list, tuple)):
588+ hostname_command = (hostname_command,)
589 return util.subp(hostname_command, capture=True)[0].strip()
590
591
592diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py
593index ce47b6b..9450835 100644
594--- a/cloudinit/sources/DataSourceOpenNebula.py
595+++ b/cloudinit/sources/DataSourceOpenNebula.py
596@@ -173,10 +173,7 @@ class OpenNebulaNetwork(object):
597 def gen_conf(self):
598 global_dns = self.context.get('DNS', "").split()
599
600- conf = []
601- conf.append('auto lo')
602- conf.append('iface lo inet loopback')
603- conf.append('')
604+ conf = ['auto lo', 'iface lo inet loopback', '']
605
606 for mac, dev in self.ifaces.items():
607 mac = mac.lower()
608diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
609index a05ca2f..df0b374 100644
610--- a/cloudinit/sources/__init__.py
611+++ b/cloudinit/sources/__init__.py
612@@ -276,21 +276,34 @@ class DataSource(object):
613 return "iid-datasource"
614 return str(self.metadata['instance-id'])
615
616- def get_hostname(self, fqdn=False, resolve_ip=False):
617+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
618+ """Get hostname or fqdn from the datasource. Look it up if desired.
619+
620+ @param fqdn: Boolean, set True to return hostname with domain.
621+ @param resolve_ip: Boolean, set True to attempt to resolve an ipv4
622+ address provided in local-hostname meta-data.
623+ @param metadata_only: Boolean, set True to avoid looking up hostname
624+ if meta-data doesn't have local-hostname present.
625+
626+ @return: hostname or qualified hostname. Optionally return None when
627+ metadata_only is True and local-hostname data is not available.
628+ """
629 defdomain = "localdomain"
630 defhost = "localhost"
631 domain = defdomain
632
633 if not self.metadata or 'local-hostname' not in self.metadata:
634+ if metadata_only:
635+ return None
636 # this is somewhat questionable really.
637 # the cloud datasource was asked for a hostname
638 # and didn't have one. raising error might be more appropriate
639 # but instead, basically look up the existing hostname
640 toks = []
641 hostname = util.get_hostname()
642- fqdn = util.get_fqdn_from_hosts(hostname)
643- if fqdn and fqdn.find(".") > 0:
644- toks = str(fqdn).split(".")
645+ hosts_fqdn = util.get_fqdn_from_hosts(hostname)
646+ if hosts_fqdn and hosts_fqdn.find(".") > 0:
647+ toks = str(hosts_fqdn).split(".")
648 elif hostname and hostname.find(".") > 0:
649 toks = str(hostname).split(".")
650 elif hostname:
651diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
652index af15115..5065083 100644
653--- a/cloudinit/sources/tests/test_init.py
654+++ b/cloudinit/sources/tests/test_init.py
655@@ -7,7 +7,7 @@ import stat
656 from cloudinit.helpers import Paths
657 from cloudinit.sources import (
658 INSTANCE_JSON_FILE, DataSource)
659-from cloudinit.tests.helpers import CiTestCase, skipIf
660+from cloudinit.tests.helpers import CiTestCase, skipIf, mock
661 from cloudinit.user_data import UserDataProcessor
662 from cloudinit import util
663
664@@ -108,6 +108,74 @@ class TestDataSource(CiTestCase):
665 self.assertEqual('userdata_raw', datasource.userdata_raw)
666 self.assertEqual('vendordata_raw', datasource.vendordata_raw)
667
668+ def test_get_hostname_strips_local_hostname_without_domain(self):
669+ """Datasource.get_hostname strips metadata local-hostname of domain."""
670+ tmp = self.tmp_dir()
671+ datasource = DataSourceTestSubclassNet(
672+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
673+ self.assertTrue(datasource.get_data())
674+ self.assertEqual(
675+ 'test-subclass-hostname', datasource.metadata['local-hostname'])
676+ self.assertEqual('test-subclass-hostname', datasource.get_hostname())
677+ datasource.metadata['local-hostname'] = 'hostname.my.domain.com'
678+ self.assertEqual('hostname', datasource.get_hostname())
679+
680+ def test_get_hostname_with_fqdn_returns_local_hostname_with_domain(self):
681+ """Datasource.get_hostname with fqdn set gets qualified hostname."""
682+ tmp = self.tmp_dir()
683+ datasource = DataSourceTestSubclassNet(
684+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
685+ self.assertTrue(datasource.get_data())
686+ datasource.metadata['local-hostname'] = 'hostname.my.domain.com'
687+ self.assertEqual(
688+ 'hostname.my.domain.com', datasource.get_hostname(fqdn=True))
689+
690+ def test_get_hostname_without_metadata_uses_system_hostname(self):
691+ """Datasource.gethostname runs util.get_hostname when no metadata."""
692+ tmp = self.tmp_dir()
693+ datasource = DataSourceTestSubclassNet(
694+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
695+ self.assertEqual({}, datasource.metadata)
696+ mock_fqdn = 'cloudinit.sources.util.get_fqdn_from_hosts'
697+ with mock.patch('cloudinit.sources.util.get_hostname') as m_gethost:
698+ with mock.patch(mock_fqdn) as m_fqdn:
699+ m_gethost.return_value = 'systemhostname.domain.com'
700+ m_fqdn.return_value = None # No maching fqdn in /etc/hosts
701+ self.assertEqual('systemhostname', datasource.get_hostname())
702+ self.assertEqual(
703+ 'systemhostname.domain.com',
704+ datasource.get_hostname(fqdn=True))
705+
706+ def test_get_hostname_without_metadata_returns_none(self):
707+ """Datasource.gethostname returns None when metadata_only and no MD."""
708+ tmp = self.tmp_dir()
709+ datasource = DataSourceTestSubclassNet(
710+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
711+ self.assertEqual({}, datasource.metadata)
712+ mock_fqdn = 'cloudinit.sources.util.get_fqdn_from_hosts'
713+ with mock.patch('cloudinit.sources.util.get_hostname') as m_gethost:
714+ with mock.patch(mock_fqdn) as m_fqdn:
715+ self.assertIsNone(datasource.get_hostname(metadata_only=True))
716+ self.assertIsNone(
717+ datasource.get_hostname(fqdn=True, metadata_only=True))
718+ self.assertEqual([], m_gethost.call_args_list)
719+ self.assertEqual([], m_fqdn.call_args_list)
720+
721+ def test_get_hostname_without_metadata_prefers_etc_hosts(self):
722+ """Datasource.gethostname prefers /etc/hosts to util.get_hostname."""
723+ tmp = self.tmp_dir()
724+ datasource = DataSourceTestSubclassNet(
725+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
726+ self.assertEqual({}, datasource.metadata)
727+ mock_fqdn = 'cloudinit.sources.util.get_fqdn_from_hosts'
728+ with mock.patch('cloudinit.sources.util.get_hostname') as m_gethost:
729+ with mock.patch(mock_fqdn) as m_fqdn:
730+ m_gethost.return_value = 'systemhostname.domain.com'
731+ m_fqdn.return_value = 'fqdnhostname.domain.com'
732+ self.assertEqual('fqdnhostname', datasource.get_hostname())
733+ self.assertEqual('fqdnhostname.domain.com',
734+ datasource.get_hostname(fqdn=True))
735+
736 def test_get_data_write_json_instance_data(self):
737 """get_data writes INSTANCE_JSON_FILE to run_dir as readonly root."""
738 tmp = self.tmp_dir()
739diff --git a/cloudinit/stages.py b/cloudinit/stages.py
740index d045268..bc4ebc8 100644
741--- a/cloudinit/stages.py
742+++ b/cloudinit/stages.py
743@@ -132,8 +132,7 @@ class Init(object):
744 return initial_dirs
745
746 def purge_cache(self, rm_instance_lnk=False):
747- rm_list = []
748- rm_list.append(self.paths.boot_finished)
749+ rm_list = [self.paths.boot_finished]
750 if rm_instance_lnk:
751 rm_list.append(self.paths.instance_link)
752 for f in rm_list:
753diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
754index 41d9a8e..14c0b0b 100644
755--- a/cloudinit/tests/helpers.py
756+++ b/cloudinit/tests/helpers.py
757@@ -409,6 +409,19 @@ except AttributeError:
758 return decorator
759
760
761+try:
762+ import jsonschema
763+ assert jsonschema # avoid pyflakes error F401: import unused
764+ _missing_jsonschema_dep = False
765+except ImportError:
766+ _missing_jsonschema_dep = True
767+
768+
769+def skipUnlessJsonSchema():
770+ return skipIf(
771+ _missing_jsonschema_dep, "No python-jsonschema dependency present.")
772+
773+
774 # older versions of mock do not have the useful 'assert_not_called'
775 if not hasattr(mock.Mock, 'assert_not_called'):
776 def __mock_assert_not_called(mmock):
777diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py
778index ba6bf69..d30643d 100644
779--- a/cloudinit/tests/test_util.py
780+++ b/cloudinit/tests/test_util.py
781@@ -16,6 +16,25 @@ MOUNT_INFO = [
782 ]
783
784
785+class FakeCloud(object):
786+
787+ def __init__(self, hostname, fqdn):
788+ self.hostname = hostname
789+ self.fqdn = fqdn
790+ self.calls = []
791+
792+ def get_hostname(self, fqdn=None, metadata_only=None):
793+ myargs = {}
794+ if fqdn is not None:
795+ myargs['fqdn'] = fqdn
796+ if metadata_only is not None:
797+ myargs['metadata_only'] = metadata_only
798+ self.calls.append(myargs)
799+ if fqdn:
800+ return self.fqdn
801+ return self.hostname
802+
803+
804 class TestUtil(CiTestCase):
805
806 def test_parse_mount_info_no_opts_no_arg(self):
807@@ -44,3 +63,81 @@ class TestUtil(CiTestCase):
808 m_mount_info.return_value = ('/dev/sda1', 'btrfs', '/', 'ro,relatime')
809 is_rw = util.mount_is_read_write('/')
810 self.assertEqual(is_rw, False)
811+
812+
813+class TestShellify(CiTestCase):
814+
815+ def test_input_dict_raises_type_error(self):
816+ self.assertRaisesRegex(
817+ TypeError, 'Input.*was.*dict.*xpected',
818+ util.shellify, {'mykey': 'myval'})
819+
820+ def test_input_str_raises_type_error(self):
821+ self.assertRaisesRegex(
822+ TypeError, 'Input.*was.*str.*xpected', util.shellify, "foobar")
823+
824+ def test_value_with_int_raises_type_error(self):
825+ self.assertRaisesRegex(
826+ TypeError, 'shellify.*int', util.shellify, ["foo", 1])
827+
828+ def test_supports_strings_and_lists(self):
829+ self.assertEqual(
830+ '\n'.join(["#!/bin/sh", "echo hi mom", "'echo' 'hi dad'",
831+ "'echo' 'hi' 'sis'", ""]),
832+ util.shellify(["echo hi mom", ["echo", "hi dad"],
833+ ('echo', 'hi', 'sis')]))
834+
835+
836+class TestGetHostnameFqdn(CiTestCase):
837+
838+ def test_get_hostname_fqdn_from_only_cfg_fqdn(self):
839+ """When cfg only has the fqdn key, derive hostname and fqdn from it."""
840+ hostname, fqdn = util.get_hostname_fqdn(
841+ cfg={'fqdn': 'myhost.domain.com'}, cloud=None)
842+ self.assertEqual('myhost', hostname)
843+ self.assertEqual('myhost.domain.com', fqdn)
844+
845+ def test_get_hostname_fqdn_from_cfg_fqdn_and_hostname(self):
846+ """When cfg has both fqdn and hostname keys, return them."""
847+ hostname, fqdn = util.get_hostname_fqdn(
848+ cfg={'fqdn': 'myhost.domain.com', 'hostname': 'other'}, cloud=None)
849+ self.assertEqual('other', hostname)
850+ self.assertEqual('myhost.domain.com', fqdn)
851+
852+ def test_get_hostname_fqdn_from_cfg_hostname_with_domain(self):
853+ """When cfg has only hostname key which represents a fqdn, use that."""
854+ hostname, fqdn = util.get_hostname_fqdn(
855+ cfg={'hostname': 'myhost.domain.com'}, cloud=None)
856+ self.assertEqual('myhost', hostname)
857+ self.assertEqual('myhost.domain.com', fqdn)
858+
859+ def test_get_hostname_fqdn_from_cfg_hostname_without_domain(self):
860+ """When cfg has a hostname without a '.' query cloud.get_hostname."""
861+ mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
862+ hostname, fqdn = util.get_hostname_fqdn(
863+ cfg={'hostname': 'myhost'}, cloud=mycloud)
864+ self.assertEqual('myhost', hostname)
865+ self.assertEqual('cloudhost.mycloud.com', fqdn)
866+ self.assertEqual(
867+ [{'fqdn': True, 'metadata_only': False}], mycloud.calls)
868+
869+ def test_get_hostname_fqdn_from_without_fqdn_or_hostname(self):
870+ """When cfg has neither hostname nor fqdn cloud.get_hostname."""
871+ mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
872+ hostname, fqdn = util.get_hostname_fqdn(cfg={}, cloud=mycloud)
873+ self.assertEqual('cloudhost', hostname)
874+ self.assertEqual('cloudhost.mycloud.com', fqdn)
875+ self.assertEqual(
876+ [{'fqdn': True, 'metadata_only': False},
877+ {'metadata_only': False}], mycloud.calls)
878+
879+ def test_get_hostname_fqdn_from_passes_metadata_only_to_cloud(self):
880+ """Calls to cloud.get_hostname pass the metadata_only parameter."""
881+ mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
882+ hostname, fqdn = util.get_hostname_fqdn(
883+ cfg={}, cloud=mycloud, metadata_only=True)
884+ self.assertEqual(
885+ [{'fqdn': True, 'metadata_only': True},
886+ {'metadata_only': True}], mycloud.calls)
887+
888+# vi: ts=4 expandtab
889diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
890index 0a5be0b..4e814a5 100644
891--- a/cloudinit/url_helper.py
892+++ b/cloudinit/url_helper.py
893@@ -47,7 +47,7 @@ try:
894 _REQ_VER = LooseVersion(_REQ.version) # pylint: disable=no-member
895 if _REQ_VER >= LooseVersion('0.8.8'):
896 SSL_ENABLED = True
897- if _REQ_VER >= LooseVersion('0.7.0') and _REQ_VER < LooseVersion('1.0.0'):
898+ if LooseVersion('0.7.0') <= _REQ_VER < LooseVersion('1.0.0'):
899 CONFIG_ENABLED = True
900 except ImportError:
901 pass
902@@ -121,7 +121,7 @@ class UrlResponse(object):
903 upper = 300
904 if redirects_ok:
905 upper = 400
906- if self.code >= 200 and self.code < upper:
907+ if 200 <= self.code < upper:
908 return True
909 else:
910 return False
911diff --git a/cloudinit/util.py b/cloudinit/util.py
912index 02dc2ce..823d80b 100644
913--- a/cloudinit/util.py
914+++ b/cloudinit/util.py
915@@ -546,7 +546,7 @@ def is_ipv4(instr):
916 return False
917
918 try:
919- toks = [x for x in toks if int(x) < 256 and int(x) >= 0]
920+ toks = [x for x in toks if 0 <= int(x) < 256]
921 except Exception:
922 return False
923
924@@ -716,8 +716,7 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
925 def make_url(scheme, host, port=None,
926 path='', params='', query='', fragment=''):
927
928- pieces = []
929- pieces.append(scheme or '')
930+ pieces = [scheme or '']
931
932 netloc = ''
933 if host:
934@@ -1026,9 +1025,16 @@ def dos2unix(contents):
935 return contents.replace('\r\n', '\n')
936
937
938-def get_hostname_fqdn(cfg, cloud):
939- # return the hostname and fqdn from 'cfg'. If not found in cfg,
940- # then fall back to data from cloud
941+def get_hostname_fqdn(cfg, cloud, metadata_only=False):
942+ """Get hostname and fqdn from config if present and fallback to cloud.
943+
944+ @param cfg: Dictionary of merged user-data configuration (from init.cfg).
945+ @param cloud: Cloud instance from init.cloudify().
946+ @param metadata_only: Boolean, set True to only query cloud meta-data,
947+ returning None if not present in meta-data.
948+ @return: a Tuple of strings <hostname>, <fqdn>. Values can be none when
949+ metadata_only is True and no cfg or metadata provides hostname info.
950+ """
951 if "fqdn" in cfg:
952 # user specified a fqdn. Default hostname then is based off that
953 fqdn = cfg['fqdn']
954@@ -1042,11 +1048,11 @@ def get_hostname_fqdn(cfg, cloud):
955 else:
956 # no fqdn set, get fqdn from cloud.
957 # get hostname from cfg if available otherwise cloud
958- fqdn = cloud.get_hostname(fqdn=True)
959+ fqdn = cloud.get_hostname(fqdn=True, metadata_only=metadata_only)
960 if "hostname" in cfg:
961 hostname = cfg['hostname']
962 else:
963- hostname = cloud.get_hostname()
964+ hostname = cloud.get_hostname(metadata_only=metadata_only)
965 return (hostname, fqdn)
966
967
968@@ -1868,8 +1874,14 @@ def subp(args, data=None, rcs=None, env=None, capture=True, shell=False,
969 # Popen converts entries in the arguments array from non-bytes to bytes.
970 # When locale is unset it may use ascii for that encoding which can
971 # cause UnicodeDecodeErrors. (LP: #1751051)
972- bytes_args = [x if isinstance(x, six.binary_type) else x.encode("utf-8")
973- for x in args]
974+ if isinstance(args, six.binary_type):
975+ bytes_args = args
976+ elif isinstance(args, six.string_types):
977+ bytes_args = args.encode("utf-8")
978+ else:
979+ bytes_args = [
980+ x if isinstance(x, six.binary_type) else x.encode("utf-8")
981+ for x in args]
982 try:
983 sp = subprocess.Popen(bytes_args, stdout=stdout,
984 stderr=stderr, stdin=stdin,
985@@ -1923,6 +1935,11 @@ def abs_join(*paths):
986 # if it is an array, shell protect it (with single ticks)
987 # if it is a string, do nothing
988 def shellify(cmdlist, add_header=True):
989+ if not isinstance(cmdlist, (tuple, list)):
990+ raise TypeError(
991+ "Input to shellify was type '%s'. Expected list or tuple." %
992+ (type_utils.obj_name(cmdlist)))
993+
994 content = ''
995 if add_header:
996 content += "#!/bin/sh\n"
997@@ -1931,7 +1948,7 @@ def shellify(cmdlist, add_header=True):
998 for args in cmdlist:
999 # If the item is a list, wrap all items in single tick.
1000 # If its not, then just write it directly.
1001- if isinstance(args, list):
1002+ if isinstance(args, (list, tuple)):
1003 fixed = []
1004 for f in args:
1005 fixed.append("'%s'" % (six.text_type(f).replace("'", escaped)))
1006@@ -1941,9 +1958,10 @@ def shellify(cmdlist, add_header=True):
1007 content = "%s%s\n" % (content, args)
1008 cmds_made += 1
1009 else:
1010- raise RuntimeError(("Unable to shellify type %s"
1011- " which is not a list or string")
1012- % (type_utils.obj_name(args)))
1013+ raise TypeError(
1014+ "Unable to shellify type '%s'. Expected list, string, tuple. "
1015+ "Got: %s" % (type_utils.obj_name(args), args))
1016+
1017 LOG.debug("Shellified %s commands.", cmds_made)
1018 return content
1019
1020diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
1021index fad1184..cf2e240 100644
1022--- a/config/cloud.cfg.tmpl
1023+++ b/config/cloud.cfg.tmpl
1024@@ -113,9 +113,9 @@ cloud_final_modules:
1025 {% if variant not in ["freebsd"] %}
1026 - puppet
1027 - chef
1028- - salt-minion
1029 - mcollective
1030 {% endif %}
1031+ - salt-minion
1032 - rightscale_userdata
1033 - scripts-vendor
1034 - scripts-per-once
1035diff --git a/debian/changelog b/debian/changelog
1036index 27dba2c..f1ba6ef 100644
1037--- a/debian/changelog
1038+++ b/debian/changelog
1039@@ -1,3 +1,25 @@
1040+cloud-init (18.1-17-g97012fbb-0ubuntu1) bionic; urgency=medium
1041+
1042+ * New upstream snapshot.
1043+ - util: Fix subp regression. Allow specifying subp command as a string.
1044+ (LP: #1755965)
1045+ - doc: fix all warnings issued by 'tox -e doc'
1046+ - FreeBSD: Set hostname to FQDN. [Dominic Schlegel] (LP: #1753499)
1047+ - tests: fix run_tree and bddeb
1048+ - tests: Fix some warnings in tests that popped up with newer python.
1049+ - set_hostname: When present in metadata, set it before network bringup.
1050+ (LP: #1746455)
1051+ - tests: Centralize and re-use skipTest based on json schema presense.
1052+ - This commit fixes get_hostname on the AzureDataSource.
1053+ [Douglas Jordan] (LP: #1754495)
1054+ - shellify: raise TypeError on bad input.
1055+ - Make salt minion module work on FreeBSD.
1056+ [Dominic Schlegel] (LP: #1721503)
1057+ - Simplify some comparisions. [Rémy Léone]
1058+ - Change some list creation and population to literal. [Rémy Léone]
1059+
1060+ -- Chad Smith <chad.smith@canonical.com> Thu, 15 Mar 2018 14:48:29 -0600
1061+
1062 cloud-init (18.1-5-g40e77380-0ubuntu1) bionic; urgency=medium
1063
1064 * New upstream snapshot.
1065diff --git a/doc/rtd/topics/capabilities.rst b/doc/rtd/topics/capabilities.rst
1066index ae3a0c7..3e2c9e3 100644
1067--- a/doc/rtd/topics/capabilities.rst
1068+++ b/doc/rtd/topics/capabilities.rst
1069@@ -44,13 +44,14 @@ Currently defined feature names include:
1070 CLI Interface
1071 =============
1072
1073- The command line documentation is accessible on any cloud-init
1074-installed system:
1075+The command line documentation is accessible on any cloud-init installed
1076+system:
1077
1078-.. code-block:: bash
1079+.. code-block:: shell-session
1080
1081 % cloud-init --help
1082 usage: cloud-init [-h] [--version] [--file FILES]
1083+
1084 [--debug] [--force]
1085 {init,modules,single,dhclient-hook,features,analyze,devel,collect-logs,clean,status}
1086 ...
1087@@ -88,7 +89,7 @@ Print out each feature supported. If cloud-init does not have the
1088 features subcommand, it also does not support any features described in
1089 this document.
1090
1091-.. code-block:: bash
1092+.. code-block:: shell-session
1093
1094 % cloud-init features
1095 NETWORK_CONFIG_V1
1096@@ -100,10 +101,11 @@ cloud-init status
1097 -----------------
1098 Report whether cloud-init is running, done, disabled or errored. Exits
1099 non-zero if an error is detected in cloud-init.
1100+
1101 * **--long**: Detailed status information.
1102 * **--wait**: Block until cloud-init completes.
1103
1104-.. code-block:: bash
1105+.. code-block:: shell-session
1106
1107 % cloud-init status --long
1108 status: done
1109@@ -214,7 +216,7 @@ of once-per-instance:
1110 * **--frequency**: Optionally override the declared module frequency
1111 with one of (always|once-per-instance|once)
1112
1113-.. code-block:: bash
1114+.. code-block:: shell-session
1115
1116 % cloud-init single --name set_hostname --frequency always
1117
1118diff --git a/doc/rtd/topics/debugging.rst b/doc/rtd/topics/debugging.rst
1119index c2b47ed..cacc8a2 100644
1120--- a/doc/rtd/topics/debugging.rst
1121+++ b/doc/rtd/topics/debugging.rst
1122@@ -1,6 +1,6 @@
1123-**********************
1124+********************************
1125 Testing and debugging cloud-init
1126-**********************
1127+********************************
1128
1129 Overview
1130 ========
1131@@ -10,7 +10,7 @@ deployed instances.
1132 .. _boot_time_analysis:
1133
1134 Boot Time Analysis - cloud-init analyze
1135-======================================
1136+=======================================
1137 Occasionally instances don't appear as performant as we would like and
1138 cloud-init packages a simple facility to inspect what operations took
1139 cloud-init the longest during boot and setup.
1140@@ -22,9 +22,9 @@ determine the long-pole in cloud-init configuration and setup. These
1141 subcommands default to reading /var/log/cloud-init.log.
1142
1143 * ``analyze show`` Parse and organize cloud-init.log events by stage and
1144-include each sub-stage granularity with time delta reports.
1145+ include each sub-stage granularity with time delta reports.
1146
1147-.. code-block:: bash
1148+.. code-block:: shell-session
1149
1150 $ cloud-init analyze show -i my-cloud-init.log
1151 -- Boot Record 01 --
1152@@ -41,9 +41,9 @@ include each sub-stage granularity with time delta reports.
1153
1154
1155 * ``analyze dump`` Parse cloud-init.log into event records and return a list of
1156-dictionaries that can be consumed for other reporting needs.
1157+ dictionaries that can be consumed for other reporting needs.
1158
1159-.. code-block:: bash
1160+.. code-block:: shell-session
1161
1162 $ cloud-init analyze blame -i my-cloud-init.log
1163 [
1164@@ -56,10 +56,10 @@ dictionaries that can be consumed for other reporting needs.
1165 },...
1166
1167 * ``analyze blame`` Parse cloud-init.log into event records and sort them based
1168-on highest time cost for quick assessment of areas of cloud-init that may need
1169-improvement.
1170+ on highest time cost for quick assessment of areas of cloud-init that may
1171+ need improvement.
1172
1173-.. code-block:: bash
1174+.. code-block:: shell-session
1175
1176 $ cloud-init analyze blame -i my-cloud-init.log
1177 -- Boot Record 11 --
1178@@ -73,31 +73,36 @@ Analyze quickstart - LXC
1179 ---------------------------
1180 To quickly obtain a cloud-init log try using lxc on any ubuntu system:
1181
1182-.. code-block:: bash
1183+.. code-block:: shell-session
1184+
1185+ $ lxc init ubuntu-daily:xenial x1
1186+ $ lxc start x1
1187+ $ # Take lxc's cloud-init.log and pipe it to the analyzer
1188+ $ lxc file pull x1/var/log/cloud-init.log - | cloud-init analyze dump -i -
1189+ $ lxc file pull x1/var/log/cloud-init.log - | \
1190+ python3 -m cloudinit.analyze dump -i -
1191
1192- $ lxc init ubuntu-daily:xenial x1
1193- $ lxc start x1
1194- # Take lxc's cloud-init.log and pipe it to the analyzer
1195- $ lxc file pull x1/var/log/cloud-init.log - | cloud-init analyze dump -i -
1196- $ lxc file pull x1/var/log/cloud-init.log - | \
1197- python3 -m cloudinit.analyze dump -i -
1198
1199 Analyze quickstart - KVM
1200 ---------------------------
1201 To quickly analyze a KVM a cloud-init log:
1202
1203 1. Download the current cloud image
1204- wget https://cloud-images.ubuntu.com/daily/server/xenial/current/xenial-server-cloudimg-amd64.img
1205+
1206+.. code-block:: shell-session
1207+
1208+ $ wget https://cloud-images.ubuntu.com/daily/server/xenial/current/xenial-server-cloudimg-amd64.img
1209+
1210 2. Create a snapshot image to preserve the original cloud-image
1211
1212-.. code-block:: bash
1213+.. code-block:: shell-session
1214
1215 $ qemu-img create -b xenial-server-cloudimg-amd64.img -f qcow2 \
1216 test-cloudinit.qcow2
1217
1218 3. Create a seed image with metadata using `cloud-localds`
1219
1220-.. code-block:: bash
1221+.. code-block:: shell-session
1222
1223 $ cat > user-data <<EOF
1224 #cloud-config
1225@@ -108,18 +113,18 @@ To quickly analyze a KVM a cloud-init log:
1226
1227 4. Launch your modified VM
1228
1229-.. code-block:: bash
1230+.. code-block:: shell-session
1231
1232 $ kvm -m 512 -net nic -net user -redir tcp:2222::22 \
1233- -drive file=test-cloudinit.qcow2,if=virtio,format=qcow2 \
1234- -drive file=my-seed.img,if=virtio,format=raw
1235+ -drive file=test-cloudinit.qcow2,if=virtio,format=qcow2 \
1236+ -drive file=my-seed.img,if=virtio,format=raw
1237
1238 5. Analyze the boot (blame, dump, show)
1239
1240-.. code-block:: bash
1241+.. code-block:: shell-session
1242
1243 $ ssh -p 2222 ubuntu@localhost 'cat /var/log/cloud-init.log' | \
1244- cloud-init analyze blame -i -
1245+ cloud-init analyze blame -i -
1246
1247
1248 Running single cloud config modules
1249@@ -136,7 +141,7 @@ prevents a module from running again if it has already been run. To ensure that
1250 a module is run again, the desired frequency can be overridden on the
1251 commandline:
1252
1253-.. code-block:: bash
1254+.. code-block:: shell-session
1255
1256 $ sudo cloud-init single --name cc_ssh --frequency always
1257 ...
1258diff --git a/doc/rtd/topics/network-config.rst b/doc/rtd/topics/network-config.rst
1259index 96c1cf5..1e99455 100644
1260--- a/doc/rtd/topics/network-config.rst
1261+++ b/doc/rtd/topics/network-config.rst
1262@@ -202,7 +202,7 @@ is helpful for examining expected output for a given input format.
1263
1264 CLI Interface :
1265
1266-.. code-block:: bash
1267+.. code-block:: shell-session
1268
1269 % tools/net-convert.py --help
1270 usage: net-convert.py [-h] --network-data PATH --kind
1271@@ -222,7 +222,7 @@ CLI Interface :
1272
1273 Example output converting V2 to sysconfig:
1274
1275-.. code-block:: bash
1276+.. code-block:: shell-session
1277
1278 % tools/net-convert.py --network-data v2.yaml --kind yaml \
1279 --output-kind sysconfig -d target
1280diff --git a/doc/rtd/topics/tests.rst b/doc/rtd/topics/tests.rst
1281index bf04bb3..cac4a6e 100644
1282--- a/doc/rtd/topics/tests.rst
1283+++ b/doc/rtd/topics/tests.rst
1284@@ -21,7 +21,7 @@ Overview
1285 In order to avoid the need for dependencies and ease the setup and
1286 configuration users can run the integration tests via tox:
1287
1288-.. code-block:: bash
1289+.. code-block:: shell-session
1290
1291 $ git clone https://git.launchpad.net/cloud-init
1292 $ cd cloud-init
1293@@ -51,7 +51,7 @@ The first example will provide a complete end-to-end run of data
1294 collection and verification. There are additional examples below
1295 explaining how to run one or the other independently.
1296
1297-.. code-block:: bash
1298+.. code-block:: shell-session
1299
1300 $ git clone https://git.launchpad.net/cloud-init
1301 $ cd cloud-init
1302@@ -93,7 +93,7 @@ If developing tests it may be necessary to see if cloud-config works as
1303 expected and the correct files are pulled down. In this case only a
1304 collect can be ran by running:
1305
1306-.. code-block:: bash
1307+.. code-block:: shell-session
1308
1309 $ tox -e citest -- collect -n xenial --data-dir /tmp/collection
1310
1311@@ -106,7 +106,7 @@ Verify
1312 When developing tests it is much easier to simply rerun the verify scripts
1313 without the more lengthy collect process. This can be done by running:
1314
1315-.. code-block:: bash
1316+.. code-block:: shell-session
1317
1318 $ tox -e citest -- verify --data-dir /tmp/collection
1319
1320@@ -133,7 +133,7 @@ cloud-init deb from or use the ``tree_run`` command using a copy of
1321 cloud-init located in a different directory, use the option ``--cloud-init
1322 /path/to/cloud-init``.
1323
1324-.. code-block:: bash
1325+.. code-block:: shell-session
1326
1327 $ tox -e citest -- tree_run --verbose \
1328 --os-name xenial --os-name stretch \
1329@@ -331,7 +331,7 @@ Integration tests are located under the `tests/cloud_tests` directory.
1330 Test configurations are placed under `configs` and the test verification
1331 scripts under `testcases`:
1332
1333-.. code-block:: bash
1334+.. code-block:: shell-session
1335
1336 cloud-init$ tree -d tests/cloud_tests/
1337 tests/cloud_tests/
1338@@ -362,7 +362,7 @@ The following would create a test case named ``example`` under the
1339 ``modules`` category with the given description, and cloud config data read
1340 in from ``/tmp/user_data``.
1341
1342-.. code-block:: bash
1343+.. code-block:: shell-session
1344
1345 $ tox -e citest -- create modules/example \
1346 -d "a simple example test case" -c "$(< /tmp/user_data)"
1347@@ -385,7 +385,7 @@ Development Checklist
1348 * Placed in the appropriate sub-folder in the test cases directory
1349 * Tested by running the test:
1350
1351- .. code-block:: bash
1352+ .. code-block:: shell-session
1353
1354 $ tox -e citest -- run -verbose \
1355 --os-name <release target> \
1356@@ -404,14 +404,14 @@ These configuration files are the standard that the AWS cli and other AWS
1357 tools utilize for interacting directly with AWS itself and are normally
1358 generated when running ``aws configure``:
1359
1360-.. code-block:: bash
1361+.. code-block:: shell-session
1362
1363 $ cat $HOME/.aws/credentials
1364 [default]
1365 aws_access_key_id = <KEY HERE>
1366 aws_secret_access_key = <KEY HERE>
1367
1368-.. code-block:: bash
1369+.. code-block:: shell-session
1370
1371 $ cat $HOME/.aws/config
1372 [default]
1373diff --git a/tests/cloud_tests/bddeb.py b/tests/cloud_tests/bddeb.py
1374index a6d5069..b9cfcfa 100644
1375--- a/tests/cloud_tests/bddeb.py
1376+++ b/tests/cloud_tests/bddeb.py
1377@@ -16,7 +16,7 @@ pre_reqs = ['devscripts', 'equivs', 'git', 'tar']
1378
1379 def _out(cmd_res):
1380 """Get clean output from cmd result."""
1381- return cmd_res[0].strip()
1382+ return cmd_res[0].decode("utf-8").strip()
1383
1384
1385 def build_deb(args, instance):
1386diff --git a/tests/cloud_tests/platforms/ec2/__init__.py b/tests/cloud_tests/platforms/ec2/__init__.py
1387new file mode 100644
1388index 0000000..e69de29
1389--- /dev/null
1390+++ b/tests/cloud_tests/platforms/ec2/__init__.py
1391diff --git a/tests/cloud_tests/platforms/lxd/__init__.py b/tests/cloud_tests/platforms/lxd/__init__.py
1392new file mode 100644
1393index 0000000..e69de29
1394--- /dev/null
1395+++ b/tests/cloud_tests/platforms/lxd/__init__.py
1396diff --git a/tests/cloud_tests/platforms/lxd/platform.py b/tests/cloud_tests/platforms/lxd/platform.py
1397index 6a01692..f7251a0 100644
1398--- a/tests/cloud_tests/platforms/lxd/platform.py
1399+++ b/tests/cloud_tests/platforms/lxd/platform.py
1400@@ -101,8 +101,4 @@ class LXDPlatform(Platform):
1401 """
1402 return self.client.images.get_by_alias(alias)
1403
1404- def destroy(self):
1405- """Clean up platform data."""
1406- super(LXDPlatform, self).destroy()
1407-
1408 # vi: ts=4 expandtab
1409diff --git a/tests/cloud_tests/platforms/nocloudkvm/__init__.py b/tests/cloud_tests/platforms/nocloudkvm/__init__.py
1410new file mode 100644
1411index 0000000..e69de29
1412--- /dev/null
1413+++ b/tests/cloud_tests/platforms/nocloudkvm/__init__.py
1414diff --git a/tests/cloud_tests/platforms/nocloudkvm/instance.py b/tests/cloud_tests/platforms/nocloudkvm/instance.py
1415index 932dc0f..33ff3f2 100644
1416--- a/tests/cloud_tests/platforms/nocloudkvm/instance.py
1417+++ b/tests/cloud_tests/platforms/nocloudkvm/instance.py
1418@@ -109,7 +109,7 @@ class NoCloudKVMInstance(Instance):
1419 if self.pid:
1420 try:
1421 c_util.subp(['kill', '-9', self.pid])
1422- except util.ProcessExectuionError:
1423+ except c_util.ProcessExecutionError:
1424 pass
1425
1426 if self.pid_file:
1427diff --git a/tests/cloud_tests/platforms/nocloudkvm/platform.py b/tests/cloud_tests/platforms/nocloudkvm/platform.py
1428index a7e6f5d..8593346 100644
1429--- a/tests/cloud_tests/platforms/nocloudkvm/platform.py
1430+++ b/tests/cloud_tests/platforms/nocloudkvm/platform.py
1431@@ -21,10 +21,6 @@ class NoCloudKVMPlatform(Platform):
1432
1433 platform_name = 'nocloud-kvm'
1434
1435- def __init__(self, config):
1436- """Set up platform."""
1437- super(NoCloudKVMPlatform, self).__init__(config)
1438-
1439 def get_image(self, img_conf):
1440 """Get image using specified image configuration.
1441
1442diff --git a/tests/cloud_tests/platforms/platforms.py b/tests/cloud_tests/platforms/platforms.py
1443index 1542b3b..abbfebb 100644
1444--- a/tests/cloud_tests/platforms/platforms.py
1445+++ b/tests/cloud_tests/platforms/platforms.py
1446@@ -2,12 +2,15 @@
1447
1448 """Base platform class."""
1449 import os
1450+import shutil
1451
1452 from simplestreams import filters, mirrors
1453 from simplestreams import util as s_util
1454
1455 from cloudinit import util as c_util
1456
1457+from tests.cloud_tests import util
1458+
1459
1460 class Platform(object):
1461 """Base class for platforms."""
1462@@ -17,7 +20,14 @@ class Platform(object):
1463 def __init__(self, config):
1464 """Set up platform."""
1465 self.config = config
1466- self._generate_ssh_keys(config['data_dir'])
1467+ self.tmpdir = util.mkdtemp()
1468+ if 'data_dir' in config:
1469+ self.data_dir = config['data_dir']
1470+ else:
1471+ self.data_dir = os.path.join(self.tmpdir, "data_dir")
1472+ os.mkdir(self.data_dir)
1473+
1474+ self._generate_ssh_keys(self.data_dir)
1475
1476 def get_image(self, img_conf):
1477 """Get image using specified image configuration.
1478@@ -29,7 +39,7 @@ class Platform(object):
1479
1480 def destroy(self):
1481 """Clean up platform data."""
1482- pass
1483+ shutil.rmtree(self.tmpdir)
1484
1485 def _generate_ssh_keys(self, data_dir):
1486 """Generate SSH keys to be used with image."""
1487diff --git a/tests/cloud_tests/testcases/modules/salt_minion.py b/tests/cloud_tests/testcases/modules/salt_minion.py
1488index f13b48a..70917a4 100644
1489--- a/tests/cloud_tests/testcases/modules/salt_minion.py
1490+++ b/tests/cloud_tests/testcases/modules/salt_minion.py
1491@@ -31,4 +31,9 @@ class Test(base.CloudTestCase):
1492 out = self.get_data_file('grains')
1493 self.assertIn('role: web', out)
1494
1495+ def test_minion_installed(self):
1496+ """Test if the salt-minion package is installed"""
1497+ out = self.get_data_file('minion_installed')
1498+ self.assertEqual(1, int(out))
1499+
1500 # vi: ts=4 expandtab
1501diff --git a/tests/cloud_tests/testcases/modules/salt_minion.yaml b/tests/cloud_tests/testcases/modules/salt_minion.yaml
1502index ab0e05b..f20b976 100644
1503--- a/tests/cloud_tests/testcases/modules/salt_minion.yaml
1504+++ b/tests/cloud_tests/testcases/modules/salt_minion.yaml
1505@@ -3,7 +3,7 @@
1506 #
1507 # 2016-11-17: Currently takes >60 seconds results in test failure
1508 #
1509-enabled: False
1510+enabled: True
1511 cloud_config: |
1512 #cloud-config
1513 salt_minion:
1514@@ -35,5 +35,8 @@ collect_scripts:
1515 grains: |
1516 #!/bin/bash
1517 cat /etc/salt/grains
1518+ minion_installed: |
1519+ #!/bin/bash
1520+ dpkg -l | grep salt-minion | grep ii | wc -l
1521
1522 # vi: ts=4 expandtab
1523diff --git a/tests/cloud_tests/util.py b/tests/cloud_tests/util.py
1524index 6ff285e..3dd4996 100644
1525--- a/tests/cloud_tests/util.py
1526+++ b/tests/cloud_tests/util.py
1527@@ -460,6 +460,10 @@ class PlatformError(IOError):
1528 IOError.__init__(self, message)
1529
1530
1531+def mkdtemp(prefix='cloud_test_data'):
1532+ return tempfile.mkdtemp(prefix=prefix)
1533+
1534+
1535 class TempDir(object):
1536 """Configurable temporary directory like tempfile.TemporaryDirectory."""
1537
1538@@ -480,7 +484,7 @@ class TempDir(object):
1539 @return_value: tempdir path
1540 """
1541 if not self.tmpdir:
1542- self.tmpdir = tempfile.mkdtemp(prefix=self.prefix)
1543+ self.tmpdir = mkdtemp(prefix=self.prefix)
1544 LOG.debug('using tmpdir: %s', self.tmpdir)
1545 return self.tmpdir
1546
1547diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
1548index 254e987..da7da0c 100644
1549--- a/tests/unittests/test_datasource/test_azure.py
1550+++ b/tests/unittests/test_datasource/test_azure.py
1551@@ -643,6 +643,21 @@ fdescfs /dev/fd fdescfs rw 0 0
1552 expected_config['config'].append(blacklist_config)
1553 self.assertEqual(netconfig, expected_config)
1554
1555+ @mock.patch("cloudinit.sources.DataSourceAzure.util.subp")
1556+ def test_get_hostname_with_no_args(self, subp):
1557+ dsaz.get_hostname()
1558+ subp.assert_called_once_with(("hostname",), capture=True)
1559+
1560+ @mock.patch("cloudinit.sources.DataSourceAzure.util.subp")
1561+ def test_get_hostname_with_string_arg(self, subp):
1562+ dsaz.get_hostname(hostname_command="hostname")
1563+ subp.assert_called_once_with(("hostname",), capture=True)
1564+
1565+ @mock.patch("cloudinit.sources.DataSourceAzure.util.subp")
1566+ def test_get_hostname_with_iterable_arg(self, subp):
1567+ dsaz.get_hostname(hostname_command=("hostname",))
1568+ subp.assert_called_once_with(("hostname",), capture=True)
1569+
1570
1571 class TestAzureBounce(CiTestCase):
1572
1573diff --git a/tests/unittests/test_handler/test_handler_bootcmd.py b/tests/unittests/test_handler/test_handler_bootcmd.py
1574index dbf43e0..29fc25e 100644
1575--- a/tests/unittests/test_handler/test_handler_bootcmd.py
1576+++ b/tests/unittests/test_handler/test_handler_bootcmd.py
1577@@ -3,17 +3,11 @@
1578 from cloudinit.config import cc_bootcmd
1579 from cloudinit.sources import DataSourceNone
1580 from cloudinit import (distros, helpers, cloud, util)
1581-from cloudinit.tests.helpers import CiTestCase, mock, skipIf
1582+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
1583
1584 import logging
1585 import tempfile
1586
1587-try:
1588- import jsonschema
1589- assert jsonschema # avoid pyflakes error F401: import unused
1590- _missing_jsonschema_dep = False
1591-except ImportError:
1592- _missing_jsonschema_dep = True
1593
1594 LOG = logging.getLogger(__name__)
1595
1596@@ -69,10 +63,10 @@ class TestBootcmd(CiTestCase):
1597 cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
1598 self.assertIn('Failed to shellify bootcmd', self.logs.getvalue())
1599 self.assertEqual(
1600- "'int' object is not iterable",
1601+ "Input to shellify was type 'int'. Expected list or tuple.",
1602 str(context_manager.exception))
1603
1604- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1605+ @skipUnlessJsonSchema()
1606 def test_handler_schema_validation_warns_non_array_type(self):
1607 """Schema validation warns of non-array type for bootcmd key.
1608
1609@@ -88,7 +82,7 @@ class TestBootcmd(CiTestCase):
1610 self.logs.getvalue())
1611 self.assertIn('Failed to shellify', self.logs.getvalue())
1612
1613- @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
1614+ @skipUnlessJsonSchema()
1615 def test_handler_schema_validation_warns_non_array_item_type(self):
1616 """Schema validation warns of non-array or string bootcmd items.
1617
1618@@ -98,7 +92,7 @@ class TestBootcmd(CiTestCase):
1619 invalid_config = {
1620 'bootcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
1621 cc = self._get_cloud('ubuntu')
1622- with self.assertRaises(RuntimeError) as context_manager:
1623+ with self.assertRaises(TypeError) as context_manager:
1624 cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
1625 expected_warnings = [
1626 'bootcmd.1: 20 is not valid under any of the given schemas',
1627@@ -110,7 +104,8 @@ class TestBootcmd(CiTestCase):
1628 self.assertIn(warning, logs)
1629 self.assertIn('Failed to shellify', logs)
1630 self.assertEqual(
1631- 'Unable to shellify type int which is not a list or string',
1632+ ("Unable to shellify type 'int'. Expected list, string, tuple. "
1633+ "Got: 20"),
1634 str(context_manager.exception))
1635
1636 def test_handler_creates_and_runs_bootcmd_script_with_instance_id(self):
1637diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
1638index 28a8455..695897c 100644
1639--- a/tests/unittests/test_handler/test_handler_ntp.py
1640+++ b/tests/unittests/test_handler/test_handler_ntp.py
1641@@ -3,7 +3,8 @@
1642 from cloudinit.config import cc_ntp
1643 from cloudinit.sources import DataSourceNone
1644 from cloudinit import (distros, helpers, cloud, util)
1645-from cloudinit.tests.helpers import FilesystemMockingTestCase, mock, skipIf
1646+from cloudinit.tests.helpers import (
1647+ FilesystemMockingTestCase, mock, skipUnlessJsonSchema)
1648
1649
1650 import os
1651@@ -24,13 +25,6 @@ NTP={% for host in servers|list + pools|list %}{{ host }} {% endfor -%}
1652 {% endif -%}
1653 """
1654
1655-try:
1656- import jsonschema
1657- assert jsonschema # avoid pyflakes error F401: import unused
1658- _missing_jsonschema_dep = False
1659-except ImportError:
1660- _missing_jsonschema_dep = True
1661-
1662
1663 class TestNtp(FilesystemMockingTestCase):
1664
1665@@ -312,7 +306,7 @@ class TestNtp(FilesystemMockingTestCase):
1666 content)
1667 self.assertNotIn('Invalid config:', self.logs.getvalue())
1668
1669- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1670+ @skipUnlessJsonSchema()
1671 def test_ntp_handler_schema_validation_warns_non_string_item_type(self):
1672 """Ntp schema validation warns of non-strings in pools or servers.
1673
1674@@ -333,7 +327,7 @@ class TestNtp(FilesystemMockingTestCase):
1675 content = stream.read()
1676 self.assertEqual("servers ['valid', None]\npools [123]\n", content)
1677
1678- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1679+ @skipUnlessJsonSchema()
1680 def test_ntp_handler_schema_validation_warns_of_non_array_type(self):
1681 """Ntp schema validation warns of non-array pools or servers types.
1682
1683@@ -354,7 +348,7 @@ class TestNtp(FilesystemMockingTestCase):
1684 content = stream.read()
1685 self.assertEqual("servers non-array\npools 123\n", content)
1686
1687- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1688+ @skipUnlessJsonSchema()
1689 def test_ntp_handler_schema_validation_warns_invalid_key_present(self):
1690 """Ntp schema validation warns of invalid keys present in ntp config.
1691
1692@@ -378,7 +372,7 @@ class TestNtp(FilesystemMockingTestCase):
1693 "servers []\npools ['0.mycompany.pool.ntp.org']\n",
1694 content)
1695
1696- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1697+ @skipUnlessJsonSchema()
1698 def test_ntp_handler_schema_validation_warns_of_duplicates(self):
1699 """Ntp schema validation warns of duplicates in servers or pools.
1700
1701diff --git a/tests/unittests/test_handler/test_handler_resizefs.py b/tests/unittests/test_handler/test_handler_resizefs.py
1702index 5aa3c49..c2a7f9f 100644
1703--- a/tests/unittests/test_handler/test_handler_resizefs.py
1704+++ b/tests/unittests/test_handler/test_handler_resizefs.py
1705@@ -7,21 +7,13 @@ from collections import namedtuple
1706 import logging
1707 import textwrap
1708
1709-from cloudinit.tests.helpers import (CiTestCase, mock, skipIf, util,
1710- wrap_and_call)
1711+from cloudinit.tests.helpers import (
1712+ CiTestCase, mock, skipUnlessJsonSchema, util, wrap_and_call)
1713
1714
1715 LOG = logging.getLogger(__name__)
1716
1717
1718-try:
1719- import jsonschema
1720- assert jsonschema # avoid pyflakes error F401: import unused
1721- _missing_jsonschema_dep = False
1722-except ImportError:
1723- _missing_jsonschema_dep = True
1724-
1725-
1726 class TestResizefs(CiTestCase):
1727 with_logs = True
1728
1729@@ -76,7 +68,7 @@ class TestResizefs(CiTestCase):
1730 'DEBUG: Skipping module named cc_resizefs, resizing disabled\n',
1731 self.logs.getvalue())
1732
1733- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1734+ @skipUnlessJsonSchema()
1735 def test_handle_schema_validation_logs_invalid_resize_rootfs_value(self):
1736 """The handle reports json schema violations as a warning.
1737
1738diff --git a/tests/unittests/test_handler/test_handler_runcmd.py b/tests/unittests/test_handler/test_handler_runcmd.py
1739index 374c1d3..dbbb271 100644
1740--- a/tests/unittests/test_handler/test_handler_runcmd.py
1741+++ b/tests/unittests/test_handler/test_handler_runcmd.py
1742@@ -3,19 +3,13 @@
1743 from cloudinit.config import cc_runcmd
1744 from cloudinit.sources import DataSourceNone
1745 from cloudinit import (distros, helpers, cloud, util)
1746-from cloudinit.tests.helpers import FilesystemMockingTestCase, skipIf
1747+from cloudinit.tests.helpers import (
1748+ FilesystemMockingTestCase, skipUnlessJsonSchema)
1749
1750 import logging
1751 import os
1752 import stat
1753
1754-try:
1755- import jsonschema
1756- assert jsonschema # avoid pyflakes error F401: import unused
1757- _missing_jsonschema_dep = False
1758-except ImportError:
1759- _missing_jsonschema_dep = True
1760-
1761 LOG = logging.getLogger(__name__)
1762
1763
1764@@ -56,7 +50,7 @@ class TestRuncmd(FilesystemMockingTestCase):
1765 ' /var/lib/cloud/instances/iid-datasource-none/scripts/runcmd',
1766 self.logs.getvalue())
1767
1768- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1769+ @skipUnlessJsonSchema()
1770 def test_handler_schema_validation_warns_non_array_type(self):
1771 """Schema validation warns of non-array type for runcmd key.
1772
1773@@ -71,7 +65,7 @@ class TestRuncmd(FilesystemMockingTestCase):
1774 self.logs.getvalue())
1775 self.assertIn('Failed to shellify', self.logs.getvalue())
1776
1777- @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
1778+ @skipUnlessJsonSchema()
1779 def test_handler_schema_validation_warns_non_array_item_type(self):
1780 """Schema validation warns of non-array or string runcmd items.
1781
1782diff --git a/tests/unittests/test_handler/test_handler_set_hostname.py b/tests/unittests/test_handler/test_handler_set_hostname.py
1783index abdc17e..d09ec23 100644
1784--- a/tests/unittests/test_handler/test_handler_set_hostname.py
1785+++ b/tests/unittests/test_handler/test_handler_set_hostname.py
1786@@ -11,6 +11,7 @@ from cloudinit.tests import helpers as t_help
1787
1788 from configobj import ConfigObj
1789 import logging
1790+import os
1791 import shutil
1792 from six import BytesIO
1793 import tempfile
1794@@ -19,14 +20,18 @@ LOG = logging.getLogger(__name__)
1795
1796
1797 class TestHostname(t_help.FilesystemMockingTestCase):
1798+
1799+ with_logs = True
1800+
1801 def setUp(self):
1802 super(TestHostname, self).setUp()
1803 self.tmp = tempfile.mkdtemp()
1804+ util.ensure_dir(os.path.join(self.tmp, 'data'))
1805 self.addCleanup(shutil.rmtree, self.tmp)
1806
1807 def _fetch_distro(self, kind):
1808 cls = distros.fetch(kind)
1809- paths = helpers.Paths({})
1810+ paths = helpers.Paths({'cloud_dir': self.tmp})
1811 return cls(kind, {}, paths)
1812
1813 def test_write_hostname_rhel(self):
1814@@ -34,7 +39,7 @@ class TestHostname(t_help.FilesystemMockingTestCase):
1815 'hostname': 'blah.blah.blah.yahoo.com',
1816 }
1817 distro = self._fetch_distro('rhel')
1818- paths = helpers.Paths({})
1819+ paths = helpers.Paths({'cloud_dir': self.tmp})
1820 ds = None
1821 cc = cloud.Cloud(ds, paths, {}, distro, None)
1822 self.patchUtils(self.tmp)
1823@@ -51,7 +56,7 @@ class TestHostname(t_help.FilesystemMockingTestCase):
1824 'hostname': 'blah.blah.blah.yahoo.com',
1825 }
1826 distro = self._fetch_distro('debian')
1827- paths = helpers.Paths({})
1828+ paths = helpers.Paths({'cloud_dir': self.tmp})
1829 ds = None
1830 cc = cloud.Cloud(ds, paths, {}, distro, None)
1831 self.patchUtils(self.tmp)
1832@@ -65,7 +70,7 @@ class TestHostname(t_help.FilesystemMockingTestCase):
1833 'hostname': 'blah.blah.blah.suse.com',
1834 }
1835 distro = self._fetch_distro('sles')
1836- paths = helpers.Paths({})
1837+ paths = helpers.Paths({'cloud_dir': self.tmp})
1838 ds = None
1839 cc = cloud.Cloud(ds, paths, {}, distro, None)
1840 self.patchUtils(self.tmp)
1841@@ -74,4 +79,48 @@ class TestHostname(t_help.FilesystemMockingTestCase):
1842 contents = util.load_file(distro.hostname_conf_fn)
1843 self.assertEqual('blah', contents.strip())
1844
1845+ def test_multiple_calls_skips_unchanged_hostname(self):
1846+ """Only new hostname or fqdn values will generate a hostname call."""
1847+ distro = self._fetch_distro('debian')
1848+ paths = helpers.Paths({'cloud_dir': self.tmp})
1849+ ds = None
1850+ cc = cloud.Cloud(ds, paths, {}, distro, None)
1851+ self.patchUtils(self.tmp)
1852+ cc_set_hostname.handle(
1853+ 'cc_set_hostname', {'hostname': 'hostname1.me.com'}, cc, LOG, [])
1854+ contents = util.load_file("/etc/hostname")
1855+ self.assertEqual('hostname1', contents.strip())
1856+ cc_set_hostname.handle(
1857+ 'cc_set_hostname', {'hostname': 'hostname1.me.com'}, cc, LOG, [])
1858+ self.assertIn(
1859+ 'DEBUG: No hostname changes. Skipping set-hostname\n',
1860+ self.logs.getvalue())
1861+ cc_set_hostname.handle(
1862+ 'cc_set_hostname', {'hostname': 'hostname2.me.com'}, cc, LOG, [])
1863+ contents = util.load_file("/etc/hostname")
1864+ self.assertEqual('hostname2', contents.strip())
1865+ self.assertIn(
1866+ 'Non-persistently setting the system hostname to hostname2',
1867+ self.logs.getvalue())
1868+
1869+ def test_error_on_distro_set_hostname_errors(self):
1870+ """Raise SetHostnameError on exceptions from distro.set_hostname."""
1871+ distro = self._fetch_distro('debian')
1872+
1873+ def set_hostname_error(hostname, fqdn):
1874+ raise Exception("OOPS on: %s" % fqdn)
1875+
1876+ distro.set_hostname = set_hostname_error
1877+ paths = helpers.Paths({'cloud_dir': self.tmp})
1878+ ds = None
1879+ cc = cloud.Cloud(ds, paths, {}, distro, None)
1880+ self.patchUtils(self.tmp)
1881+ with self.assertRaises(cc_set_hostname.SetHostnameError) as ctx_mgr:
1882+ cc_set_hostname.handle(
1883+ 'somename', {'hostname': 'hostname1.me.com'}, cc, LOG, [])
1884+ self.assertEqual(
1885+ 'Failed to set the hostname to hostname1.me.com (hostname1):'
1886+ ' OOPS on: hostname1.me.com',
1887+ str(ctx_mgr.exception))
1888+
1889 # vi: ts=4 expandtab
1890diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
1891index df67a0e..1ecb6c6 100644
1892--- a/tests/unittests/test_handler/test_schema.py
1893+++ b/tests/unittests/test_handler/test_schema.py
1894@@ -6,7 +6,7 @@ from cloudinit.config.schema import (
1895 validate_cloudconfig_schema, main)
1896 from cloudinit.util import subp, write_file
1897
1898-from cloudinit.tests.helpers import CiTestCase, mock, skipIf
1899+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
1900
1901 from copy import copy
1902 import os
1903@@ -14,13 +14,6 @@ from six import StringIO
1904 from textwrap import dedent
1905 from yaml import safe_load
1906
1907-try:
1908- import jsonschema
1909- assert jsonschema # avoid pyflakes error F401: import unused
1910- _missing_jsonschema_dep = False
1911-except ImportError:
1912- _missing_jsonschema_dep = True
1913-
1914
1915 class GetSchemaTest(CiTestCase):
1916
1917@@ -73,7 +66,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
1918
1919 with_logs = True
1920
1921- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1922+ @skipUnlessJsonSchema()
1923 def test_validateconfig_schema_non_strict_emits_warnings(self):
1924 """When strict is False validate_cloudconfig_schema emits warnings."""
1925 schema = {'properties': {'p1': {'type': 'string'}}}
1926@@ -82,7 +75,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
1927 "Invalid config:\np1: -1 is not of type 'string'\n",
1928 self.logs.getvalue())
1929
1930- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1931+ @skipUnlessJsonSchema()
1932 def test_validateconfig_schema_emits_warning_on_missing_jsonschema(self):
1933 """Warning from validate_cloudconfig_schema when missing jsonschema."""
1934 schema = {'properties': {'p1': {'type': 'string'}}}
1935@@ -92,7 +85,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
1936 'Ignoring schema validation. python-jsonschema is not present',
1937 self.logs.getvalue())
1938
1939- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1940+ @skipUnlessJsonSchema()
1941 def test_validateconfig_schema_strict_raises_errors(self):
1942 """When strict is True validate_cloudconfig_schema raises errors."""
1943 schema = {'properties': {'p1': {'type': 'string'}}}
1944@@ -102,7 +95,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
1945 "Cloud config schema errors: p1: -1 is not of type 'string'",
1946 str(context_mgr.exception))
1947
1948- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1949+ @skipUnlessJsonSchema()
1950 def test_validateconfig_schema_honors_formats(self):
1951 """With strict True, validate_cloudconfig_schema errors on format."""
1952 schema = {
1953@@ -153,7 +146,7 @@ class ValidateCloudConfigFileTest(CiTestCase):
1954 self.config_file),
1955 str(context_mgr.exception))
1956
1957- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1958+ @skipUnlessJsonSchema()
1959 def test_validateconfig_file_sctricty_validates_schema(self):
1960 """validate_cloudconfig_file raises errors on invalid schema."""
1961 schema = {
1962@@ -376,7 +369,7 @@ class CloudTestsIntegrationTest(CiTestCase):
1963 raises Warnings or errors on invalid cloud-config schema.
1964 """
1965
1966- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
1967+ @skipUnlessJsonSchema()
1968 def test_all_integration_test_cloud_config_schema(self):
1969 """Validate schema of cloud_tests yaml files looking for warnings."""
1970 schema = get_schema()
1971diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
1972index 89ae40f..499e7c9 100644
1973--- a/tests/unittests/test_util.py
1974+++ b/tests/unittests/test_util.py
1975@@ -632,6 +632,24 @@ class TestSubp(helpers.CiTestCase):
1976 # but by using bash, we remove dependency on another program.
1977 return([BASH, '-c', 'printf "$@"', 'printf'] + list(args))
1978
1979+ def test_subp_handles_bytestrings(self):
1980+ """subp can run a bytestring command if shell is True."""
1981+ tmp_file = self.tmp_path('test.out')
1982+ cmd = 'echo HI MOM >> {tmp_file}'.format(tmp_file=tmp_file)
1983+ (out, _err) = util.subp(cmd.encode('utf-8'), shell=True)
1984+ self.assertEqual(u'', out)
1985+ self.assertEqual(u'', _err)
1986+ self.assertEqual('HI MOM\n', util.load_file(tmp_file))
1987+
1988+ def test_subp_handles_strings(self):
1989+ """subp can run a string command if shell is True."""
1990+ tmp_file = self.tmp_path('test.out')
1991+ cmd = 'echo HI MOM >> {tmp_file}'.format(tmp_file=tmp_file)
1992+ (out, _err) = util.subp(cmd, shell=True)
1993+ self.assertEqual(u'', out)
1994+ self.assertEqual(u'', _err)
1995+ self.assertEqual('HI MOM\n', util.load_file(tmp_file))
1996+
1997 def test_subp_handles_utf8(self):
1998 # The given bytes contain utf-8 accented characters as seen in e.g.
1999 # the "deja dup" package in Ubuntu.

Subscribers

People subscribed via source and target branches