Merge ~raharper/cloud-init:ubuntu/devel/newupstream-20180426 into cloud-init:ubuntu/devel

Proposed by Ryan Harper
Status: Merged
Merged at revision: 195d6130fd8a39efd08214edfae5eceb050e432d
Proposed branch: ~raharper/cloud-init:ubuntu/devel/newupstream-20180426
Merge into: cloud-init:ubuntu/devel
Diff against target: 2991 lines (+1186/-256)
75 files modified
.pylintrc (+1/-1)
cloudinit/analyze/dump.py (+1/-1)
cloudinit/cmd/tests/test_main.py (+3/-3)
cloudinit/config/cc_apt_configure.py (+1/-1)
cloudinit/config/cc_bootcmd.py (+0/-1)
cloudinit/config/cc_disk_setup.py (+4/-8)
cloudinit/config/cc_emit_upstart.py (+1/-1)
cloudinit/config/cc_resizefs.py (+3/-5)
cloudinit/config/cc_rh_subscription.py (+8/-10)
cloudinit/config/cc_runcmd.py (+0/-1)
cloudinit/config/cc_set_passwords.py (+45/-60)
cloudinit/config/cc_snap.py (+2/-3)
cloudinit/config/cc_snappy.py (+2/-2)
cloudinit/config/cc_ubuntu_advantage.py (+2/-3)
cloudinit/config/schema.py (+2/-2)
cloudinit/config/tests/test_set_passwords.py (+71/-0)
cloudinit/config/tests/test_snap.py (+27/-2)
cloudinit/config/tests/test_ubuntu_advantage.py (+28/-2)
cloudinit/distros/freebsd.py (+1/-1)
cloudinit/distros/ubuntu.py (+1/-1)
cloudinit/net/__init__.py (+27/-1)
cloudinit/net/cmdline.py (+1/-1)
cloudinit/net/dhcp.py (+1/-1)
cloudinit/net/sysconfig.py (+1/-2)
cloudinit/net/tests/test_init.py (+1/-0)
cloudinit/reporting/events.py (+1/-1)
cloudinit/sources/DataSourceAliYun.py (+1/-1)
cloudinit/sources/DataSourceAltCloud.py (+1/-4)
cloudinit/sources/DataSourceAzure.py (+13/-20)
cloudinit/sources/DataSourceIBMCloud.py (+92/-14)
cloudinit/sources/DataSourceMAAS.py (+1/-1)
cloudinit/sources/DataSourceOVF.py (+1/-1)
cloudinit/sources/DataSourceOpenStack.py (+2/-2)
cloudinit/sources/DataSourceSmartOS.py (+13/-5)
cloudinit/sources/helpers/digitalocean.py (+3/-4)
cloudinit/sources/helpers/openstack.py (+1/-1)
cloudinit/sources/helpers/vmware/imc/config_nic.py (+1/-1)
cloudinit/sources/helpers/vmware/imc/config_passwd.py (+2/-2)
cloudinit/sources/helpers/vmware/imc/guestcust_util.py (+2/-2)
cloudinit/sources/tests/test_init.py (+1/-1)
cloudinit/ssh_util.py (+63/-7)
cloudinit/templater.py (+1/-1)
cloudinit/tests/helpers.py (+32/-2)
cloudinit/tests/test_util.py (+50/-1)
cloudinit/url_helper.py (+1/-1)
cloudinit/util.py (+16/-1)
debian/changelog (+25/-0)
doc/rtd/topics/datasources.rst (+1/-0)
doc/rtd/topics/datasources/aliyun.rst (+74/-0)
packages/debian/control.in (+1/-0)
tests/cloud_tests/bddeb.py (+1/-1)
tests/cloud_tests/collect.py (+2/-1)
tests/cloud_tests/platforms/instances.py (+1/-1)
tests/cloud_tests/platforms/lxd/instance.py (+4/-6)
tests/cloud_tests/setup_image.py (+5/-6)
tests/cloud_tests/testcases/base.py (+1/-1)
tests/cloud_tests/testcases/examples/including_user_groups.py (+1/-1)
tests/cloud_tests/testcases/modules/user_groups.py (+1/-1)
tests/cloud_tests/util.py (+1/-1)
tests/unittests/test__init__.py (+1/-1)
tests/unittests/test_datasource/test_azure.py (+2/-2)
tests/unittests/test_datasource/test_ibmcloud.py (+50/-0)
tests/unittests/test_datasource/test_maas.py (+2/-2)
tests/unittests/test_datasource/test_nocloud.py (+0/-3)
tests/unittests/test_datasource/test_smartos.py (+117/-4)
tests/unittests/test_ds_identify.py (+64/-8)
tests/unittests/test_handler/test_handler_apt_source_v3.py (+1/-1)
tests/unittests/test_handler/test_handler_bootcmd.py (+26/-8)
tests/unittests/test_handler/test_handler_ntp.py (+1/-1)
tests/unittests/test_handler/test_handler_runcmd.py (+26/-7)
tests/unittests/test_net.py (+127/-5)
tests/unittests/test_sshutil.py (+94/-3)
tests/unittests/test_templating.py (+2/-2)
tests/unittests/test_util.py (+3/-3)
tools/ds-identify (+20/-1)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
cloud-init Commiters Pending
Review via email: mp+344560@code.launchpad.net

Commit message

cloud-init (18.2-27-g6ef92c98-0ubuntu1) bionic; urgency=medium

  * New upstream snapshot.
    - IBMCloud: recognize provisioning environment during debug boots.
      (LP: #1767166)
    - net: detect unstable network names and trigger a settle if needed
      (LP: #1766287)
    - IBMCloud: improve documentation in datasource.
    - sysconfig: dhcp6 subnet type should not imply dhcpv4 [Vitaly Kuznetsov]
    - packages/debian/control.in: add missing dependency on iproute2.
      (LP: #1766711)
    - DataSourceSmartOS: add locking of serial device.
      [Mike Gerdts] (LP: #1746605)
    - DataSourceSmartOS: sdc:hostname is ignored [Mike Gerdts] (LP: #1765085)
    - DataSourceSmartOS: list() should always return a list
      [Mike Gerdts] (LP: #1763480)
    - schema: in validation, raise ImportError if strict but no jsonschema.
    - set_passwords: Add newline to end of sshd config, only restart if
      updated. (LP: #1677205)
    - pylint: pay attention to unused variable warnings.
    - doc: Add documentation for AliYun datasource. [Junjie Wang]
    - Schema: do not warn on duplicate items in commands. (LP: #1764264)

 -- Ryan Harper <email address hidden> Thu, 26 Apr 2018 16:33:59 -0500

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:7609065fd30c3a02122bded6ab213ebc3912584b
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1071/
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/1071/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 0bdfa59..3bfa0c8 100644
3--- a/.pylintrc
4+++ b/.pylintrc
5@@ -28,7 +28,7 @@ jobs=4
6 # W0703(broad-except)
7 # W1401(anomalous-backslash-in-string)
8
9-disable=C, F, I, R, W0105, W0107, W0201, W0212, W0221, W0222, W0223, W0231, W0311, W0511, W0602, W0603, W0611, W0612, W0613, W0621, W0622, W0631, W0703, W1401
10+disable=C, F, I, R, W0105, W0107, W0201, W0212, W0221, W0222, W0223, W0231, W0311, W0511, W0602, W0603, W0611, W0613, W0621, W0622, W0631, W0703, W1401
11
12
13 [REPORTS]
14diff --git a/cloudinit/analyze/dump.py b/cloudinit/analyze/dump.py
15index b071aa1..1f3060d 100644
16--- a/cloudinit/analyze/dump.py
17+++ b/cloudinit/analyze/dump.py
18@@ -112,7 +112,7 @@ def parse_ci_logline(line):
19 return None
20 event_description = stage_to_description[event_name]
21 else:
22- (pymodloglvl, event_type, event_name) = eventstr.split()[0:3]
23+ (_pymodloglvl, event_type, event_name) = eventstr.split()[0:3]
24 event_description = eventstr.split(event_name)[1].strip()
25
26 event = {
27diff --git a/cloudinit/cmd/tests/test_main.py b/cloudinit/cmd/tests/test_main.py
28index dbe421c..e2c54ae 100644
29--- a/cloudinit/cmd/tests/test_main.py
30+++ b/cloudinit/cmd/tests/test_main.py
31@@ -56,7 +56,7 @@ class TestMain(FilesystemMockingTestCase):
32 cmdargs = myargs(
33 debug=False, files=None, force=False, local=False, reporter=None,
34 subcommand='init')
35- (item1, item2) = wrap_and_call(
36+ (_item1, item2) = wrap_and_call(
37 'cloudinit.cmd.main',
38 {'util.close_stdin': True,
39 'netinfo.debug_info': 'my net debug info',
40@@ -85,7 +85,7 @@ class TestMain(FilesystemMockingTestCase):
41 cmdargs = myargs(
42 debug=False, files=None, force=False, local=False, reporter=None,
43 subcommand='init')
44- (item1, item2) = wrap_and_call(
45+ (_item1, item2) = wrap_and_call(
46 'cloudinit.cmd.main',
47 {'util.close_stdin': True,
48 'netinfo.debug_info': 'my net debug info',
49@@ -133,7 +133,7 @@ class TestMain(FilesystemMockingTestCase):
50 self.assertEqual(main.LOG, log)
51 self.assertIsNone(args)
52
53- (item1, item2) = wrap_and_call(
54+ (_item1, item2) = wrap_and_call(
55 'cloudinit.cmd.main',
56 {'util.close_stdin': True,
57 'netinfo.debug_info': 'my net debug info',
58diff --git a/cloudinit/config/cc_apt_configure.py b/cloudinit/config/cc_apt_configure.py
59index afaca46..e18944e 100644
60--- a/cloudinit/config/cc_apt_configure.py
61+++ b/cloudinit/config/cc_apt_configure.py
62@@ -378,7 +378,7 @@ def apply_debconf_selections(cfg, target=None):
63
64 # get a complete list of packages listed in input
65 pkgs_cfgd = set()
66- for key, content in selsets.items():
67+ for _key, content in selsets.items():
68 for line in content.splitlines():
69 if line.startswith("#"):
70 continue
71diff --git a/cloudinit/config/cc_bootcmd.py b/cloudinit/config/cc_bootcmd.py
72index 233da1e..db64f0a 100644
73--- a/cloudinit/config/cc_bootcmd.py
74+++ b/cloudinit/config/cc_bootcmd.py
75@@ -63,7 +63,6 @@ schema = {
76 'additionalProperties': False,
77 'minItems': 1,
78 'required': [],
79- 'uniqueItems': True
80 }
81 }
82 }
83diff --git a/cloudinit/config/cc_disk_setup.py b/cloudinit/config/cc_disk_setup.py
84index c3e8c48..943089e 100644
85--- a/cloudinit/config/cc_disk_setup.py
86+++ b/cloudinit/config/cc_disk_setup.py
87@@ -680,13 +680,13 @@ def read_parttbl(device):
88 reliable way to probe the partition table.
89 """
90 blkdev_cmd = [BLKDEV_CMD, '--rereadpt', device]
91- udevadm_settle()
92+ util.udevadm_settle()
93 try:
94 util.subp(blkdev_cmd)
95 except Exception as e:
96 util.logexc(LOG, "Failed reading the partition table %s" % e)
97
98- udevadm_settle()
99+ util.udevadm_settle()
100
101
102 def exec_mkpart_mbr(device, layout):
103@@ -737,14 +737,10 @@ def exec_mkpart(table_type, device, layout):
104 return get_dyn_func("exec_mkpart_%s", table_type, device, layout)
105
106
107-def udevadm_settle():
108- util.subp(['udevadm', 'settle'])
109-
110-
111 def assert_and_settle_device(device):
112 """Assert that device exists and settle so it is fully recognized."""
113 if not os.path.exists(device):
114- udevadm_settle()
115+ util.udevadm_settle()
116 if not os.path.exists(device):
117 raise RuntimeError("Device %s did not exist and was not created "
118 "with a udevamd settle." % device)
119@@ -752,7 +748,7 @@ def assert_and_settle_device(device):
120 # Whether or not the device existed above, it is possible that udev
121 # events that would populate udev database (for reading by lsdname) have
122 # not yet finished. So settle again.
123- udevadm_settle()
124+ util.udevadm_settle()
125
126
127 def mkpart(device, definition):
128diff --git a/cloudinit/config/cc_emit_upstart.py b/cloudinit/config/cc_emit_upstart.py
129index 69dc2d5..eb9fbe6 100644
130--- a/cloudinit/config/cc_emit_upstart.py
131+++ b/cloudinit/config/cc_emit_upstart.py
132@@ -43,7 +43,7 @@ def is_upstart_system():
133 del myenv['UPSTART_SESSION']
134 check_cmd = ['initctl', 'version']
135 try:
136- (out, err) = util.subp(check_cmd, env=myenv)
137+ (out, _err) = util.subp(check_cmd, env=myenv)
138 return 'upstart' in out
139 except util.ProcessExecutionError as e:
140 LOG.debug("'%s' returned '%s', not using upstart",
141diff --git a/cloudinit/config/cc_resizefs.py b/cloudinit/config/cc_resizefs.py
142index 013e69b..82f29e1 100644
143--- a/cloudinit/config/cc_resizefs.py
144+++ b/cloudinit/config/cc_resizefs.py
145@@ -89,13 +89,11 @@ def _resize_zfs(mount_point, devpth):
146
147
148 def _get_dumpfs_output(mount_point):
149- dumpfs_res, err = util.subp(['dumpfs', '-m', mount_point])
150- return dumpfs_res
151+ return util.subp(['dumpfs', '-m', mount_point])[0]
152
153
154 def _get_gpart_output(part):
155- gpart_res, err = util.subp(['gpart', 'show', part])
156- return gpart_res
157+ return util.subp(['gpart', 'show', part])[0]
158
159
160 def _can_skip_resize_ufs(mount_point, devpth):
161@@ -113,7 +111,7 @@ def _can_skip_resize_ufs(mount_point, devpth):
162 if not line.startswith('#'):
163 newfs_cmd = shlex.split(line)
164 opt_value = 'O:Ua:s:b:d:e:f:g:h:i:jk:m:o:'
165- optlist, args = getopt.getopt(newfs_cmd[1:], opt_value)
166+ optlist, _args = getopt.getopt(newfs_cmd[1:], opt_value)
167 for o, a in optlist:
168 if o == "-s":
169 cur_fs_sz = int(a)
170diff --git a/cloudinit/config/cc_rh_subscription.py b/cloudinit/config/cc_rh_subscription.py
171index 530808c..1c67943 100644
172--- a/cloudinit/config/cc_rh_subscription.py
173+++ b/cloudinit/config/cc_rh_subscription.py
174@@ -209,8 +209,7 @@ class SubscriptionManager(object):
175 cmd.append("--serverurl={0}".format(self.server_hostname))
176
177 try:
178- return_out, return_err = self._sub_man_cli(cmd,
179- logstring_val=True)
180+ return_out = self._sub_man_cli(cmd, logstring_val=True)[0]
181 except util.ProcessExecutionError as e:
182 if e.stdout == "":
183 self.log_warn("Registration failed due "
184@@ -233,8 +232,7 @@ class SubscriptionManager(object):
185
186 # Attempting to register the system only
187 try:
188- return_out, return_err = self._sub_man_cli(cmd,
189- logstring_val=True)
190+ return_out = self._sub_man_cli(cmd, logstring_val=True)[0]
191 except util.ProcessExecutionError as e:
192 if e.stdout == "":
193 self.log_warn("Registration failed due "
194@@ -257,7 +255,7 @@ class SubscriptionManager(object):
195 .format(self.servicelevel)]
196
197 try:
198- return_out, return_err = self._sub_man_cli(cmd)
199+ return_out = self._sub_man_cli(cmd)[0]
200 except util.ProcessExecutionError as e:
201 if e.stdout.rstrip() != '':
202 for line in e.stdout.split("\n"):
203@@ -275,7 +273,7 @@ class SubscriptionManager(object):
204 def _set_auto_attach(self):
205 cmd = ['attach', '--auto']
206 try:
207- return_out, return_err = self._sub_man_cli(cmd)
208+ return_out = self._sub_man_cli(cmd)[0]
209 except util.ProcessExecutionError as e:
210 self.log_warn("Auto-attach failed with: {0}".format(e))
211 return False
212@@ -294,12 +292,12 @@ class SubscriptionManager(object):
213
214 # Get all available pools
215 cmd = ['list', '--available', '--pool-only']
216- results, errors = self._sub_man_cli(cmd)
217+ results = self._sub_man_cli(cmd)[0]
218 available = (results.rstrip()).split("\n")
219
220 # Get all consumed pools
221 cmd = ['list', '--consumed', '--pool-only']
222- results, errors = self._sub_man_cli(cmd)
223+ results = self._sub_man_cli(cmd)[0]
224 consumed = (results.rstrip()).split("\n")
225
226 return available, consumed
227@@ -311,14 +309,14 @@ class SubscriptionManager(object):
228 '''
229
230 cmd = ['repos', '--list-enabled']
231- return_out, return_err = self._sub_man_cli(cmd)
232+ return_out = self._sub_man_cli(cmd)[0]
233 active_repos = []
234 for repo in return_out.split("\n"):
235 if "Repo ID:" in repo:
236 active_repos.append((repo.split(':')[1]).strip())
237
238 cmd = ['repos', '--list-disabled']
239- return_out, return_err = self._sub_man_cli(cmd)
240+ return_out = self._sub_man_cli(cmd)[0]
241
242 inactive_repos = []
243 for repo in return_out.split("\n"):
244diff --git a/cloudinit/config/cc_runcmd.py b/cloudinit/config/cc_runcmd.py
245index 539cbd5..b6f6c80 100644
246--- a/cloudinit/config/cc_runcmd.py
247+++ b/cloudinit/config/cc_runcmd.py
248@@ -66,7 +66,6 @@ schema = {
249 'additionalProperties': False,
250 'minItems': 1,
251 'required': [],
252- 'uniqueItems': True
253 }
254 }
255 }
256diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py
257index bb24d57..5ef9737 100755
258--- a/cloudinit/config/cc_set_passwords.py
259+++ b/cloudinit/config/cc_set_passwords.py
260@@ -68,16 +68,57 @@ import re
261 import sys
262
263 from cloudinit.distros import ug_util
264-from cloudinit import ssh_util
265+from cloudinit import log as logging
266+from cloudinit.ssh_util import update_ssh_config
267 from cloudinit import util
268
269 from string import ascii_letters, digits
270
271+LOG = logging.getLogger(__name__)
272+
273 # We are removing certain 'painful' letters/numbers
274 PW_SET = (''.join([x for x in ascii_letters + digits
275 if x not in 'loLOI01']))
276
277
278+def handle_ssh_pwauth(pw_auth, service_cmd=None, service_name="ssh"):
279+ """Apply sshd PasswordAuthentication changes.
280+
281+ @param pw_auth: config setting from 'pw_auth'.
282+ Best given as True, False, or "unchanged".
283+ @param service_cmd: The service command list (['service'])
284+ @param service_name: The name of the sshd service for the system.
285+
286+ @return: None"""
287+ cfg_name = "PasswordAuthentication"
288+ if service_cmd is None:
289+ service_cmd = ["service"]
290+
291+ if util.is_true(pw_auth):
292+ cfg_val = 'yes'
293+ elif util.is_false(pw_auth):
294+ cfg_val = 'no'
295+ else:
296+ bmsg = "Leaving ssh config '%s' unchanged." % cfg_name
297+ if pw_auth is None or pw_auth.lower() == 'unchanged':
298+ LOG.debug("%s ssh_pwauth=%s", bmsg, pw_auth)
299+ else:
300+ LOG.warning("%s Unrecognized value: ssh_pwauth=%s", bmsg, pw_auth)
301+ return
302+
303+ updated = update_ssh_config({cfg_name: cfg_val})
304+ if not updated:
305+ LOG.debug("No need to restart ssh service, %s not updated.", cfg_name)
306+ return
307+
308+ if 'systemctl' in service_cmd:
309+ cmd = list(service_cmd) + ["restart", service_name]
310+ else:
311+ cmd = list(service_cmd) + [service_name, "restart"]
312+ util.subp(cmd)
313+ LOG.debug("Restarted the ssh daemon.")
314+
315+
316 def handle(_name, cfg, cloud, log, args):
317 if len(args) != 0:
318 # if run from command line, and give args, wipe the chpasswd['list']
319@@ -170,65 +211,9 @@ def handle(_name, cfg, cloud, log, args):
320 if expired_users:
321 log.debug("Expired passwords for: %s users", expired_users)
322
323- change_pwauth = False
324- pw_auth = None
325- if 'ssh_pwauth' in cfg:
326- if util.is_true(cfg['ssh_pwauth']):
327- change_pwauth = True
328- pw_auth = 'yes'
329- elif util.is_false(cfg['ssh_pwauth']):
330- change_pwauth = True
331- pw_auth = 'no'
332- elif str(cfg['ssh_pwauth']).lower() == 'unchanged':
333- log.debug('Leaving auth line unchanged')
334- change_pwauth = False
335- elif not str(cfg['ssh_pwauth']).strip():
336- log.debug('Leaving auth line unchanged')
337- change_pwauth = False
338- elif not cfg['ssh_pwauth']:
339- log.debug('Leaving auth line unchanged')
340- change_pwauth = False
341- else:
342- msg = 'Unrecognized value %s for ssh_pwauth' % cfg['ssh_pwauth']
343- util.logexc(log, msg)
344-
345- if change_pwauth:
346- replaced_auth = False
347-
348- # See: man sshd_config
349- old_lines = ssh_util.parse_ssh_config(ssh_util.DEF_SSHD_CFG)
350- new_lines = []
351- i = 0
352- for (i, line) in enumerate(old_lines):
353- # Keywords are case-insensitive and arguments are case-sensitive
354- if line.key == 'passwordauthentication':
355- log.debug("Replacing auth line %s with %s", i + 1, pw_auth)
356- replaced_auth = True
357- line.value = pw_auth
358- new_lines.append(line)
359-
360- if not replaced_auth:
361- log.debug("Adding new auth line %s", i + 1)
362- replaced_auth = True
363- new_lines.append(ssh_util.SshdConfigLine('',
364- 'PasswordAuthentication',
365- pw_auth))
366-
367- lines = [str(l) for l in new_lines]
368- util.write_file(ssh_util.DEF_SSHD_CFG, "\n".join(lines),
369- copy_mode=True)
370-
371- try:
372- cmd = cloud.distro.init_cmd # Default service
373- cmd.append(cloud.distro.get_option('ssh_svcname', 'ssh'))
374- cmd.append('restart')
375- if 'systemctl' in cmd: # Switch action ordering
376- cmd[1], cmd[2] = cmd[2], cmd[1]
377- cmd = filter(None, cmd) # Remove empty arguments
378- util.subp(cmd)
379- log.debug("Restarted the ssh daemon")
380- except Exception:
381- util.logexc(log, "Restarting of the ssh daemon failed")
382+ handle_ssh_pwauth(
383+ cfg.get('ssh_pwauth'), service_cmd=cloud.distro.init_cmd,
384+ service_name=cloud.distro.get_option('ssh_svcname', 'ssh'))
385
386 if len(errors):
387 log.debug("%s errors occured, re-raising the last one", len(errors))
388diff --git a/cloudinit/config/cc_snap.py b/cloudinit/config/cc_snap.py
389index 34a53fd..90724b8 100644
390--- a/cloudinit/config/cc_snap.py
391+++ b/cloudinit/config/cc_snap.py
392@@ -110,7 +110,6 @@ schema = {
393 'additionalItems': False, # Reject non-string & non-list
394 'minItems': 1,
395 'minProperties': 1,
396- 'uniqueItems': True
397 },
398 'squashfuse_in_container': {
399 'type': 'boolean'
400@@ -204,12 +203,12 @@ def maybe_install_squashfuse(cloud):
401 return
402 try:
403 cloud.distro.update_package_sources()
404- except Exception as e:
405+ except Exception:
406 util.logexc(LOG, "Package update failed")
407 raise
408 try:
409 cloud.distro.install_packages(['squashfuse'])
410- except Exception as e:
411+ except Exception:
412 util.logexc(LOG, "Failed to install squashfuse")
413 raise
414
415diff --git a/cloudinit/config/cc_snappy.py b/cloudinit/config/cc_snappy.py
416index bab80bb..15bee2d 100644
417--- a/cloudinit/config/cc_snappy.py
418+++ b/cloudinit/config/cc_snappy.py
419@@ -213,7 +213,7 @@ def render_snap_op(op, name, path=None, cfgfile=None, config=None):
420
421 def read_installed_packages():
422 ret = []
423- for (name, date, version, dev) in read_pkg_data():
424+ for (name, _date, _version, dev) in read_pkg_data():
425 if dev:
426 ret.append(NAMESPACE_DELIM.join([name, dev]))
427 else:
428@@ -222,7 +222,7 @@ def read_installed_packages():
429
430
431 def read_pkg_data():
432- out, err = util.subp([SNAPPY_CMD, "list"])
433+ out, _err = util.subp([SNAPPY_CMD, "list"])
434 pkg_data = []
435 for line in out.splitlines()[1:]:
436 toks = line.split(sep=None, maxsplit=3)
437diff --git a/cloudinit/config/cc_ubuntu_advantage.py b/cloudinit/config/cc_ubuntu_advantage.py
438index 16b1868..5e082bd 100644
439--- a/cloudinit/config/cc_ubuntu_advantage.py
440+++ b/cloudinit/config/cc_ubuntu_advantage.py
441@@ -87,7 +87,6 @@ schema = {
442 'additionalItems': False, # Reject non-string & non-list
443 'minItems': 1,
444 'minProperties': 1,
445- 'uniqueItems': True
446 }
447 },
448 'additionalProperties': False, # Reject keys not in schema
449@@ -149,12 +148,12 @@ def maybe_install_ua_tools(cloud):
450 return
451 try:
452 cloud.distro.update_package_sources()
453- except Exception as e:
454+ except Exception:
455 util.logexc(LOG, "Package update failed")
456 raise
457 try:
458 cloud.distro.install_packages(['ubuntu-advantage-tools'])
459- except Exception as e:
460+ except Exception:
461 util.logexc(LOG, "Failed to install ubuntu-advantage-tools")
462 raise
463
464diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py
465index ca7d0d5..76826e0 100644
466--- a/cloudinit/config/schema.py
467+++ b/cloudinit/config/schema.py
468@@ -297,8 +297,8 @@ def get_schema():
469
470 configs_dir = os.path.dirname(os.path.abspath(__file__))
471 potential_handlers = find_modules(configs_dir)
472- for (fname, mod_name) in potential_handlers.items():
473- mod_locs, looked_locs = importer.find_module(
474+ for (_fname, mod_name) in potential_handlers.items():
475+ mod_locs, _looked_locs = importer.find_module(
476 mod_name, ['cloudinit.config'], ['schema'])
477 if mod_locs:
478 mod = importer.import_module(mod_locs[0])
479diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py
480new file mode 100644
481index 0000000..b051ec8
482--- /dev/null
483+++ b/cloudinit/config/tests/test_set_passwords.py
484@@ -0,0 +1,71 @@
485+# This file is part of cloud-init. See LICENSE file for license information.
486+
487+import mock
488+
489+from cloudinit.config import cc_set_passwords as setpass
490+from cloudinit.tests.helpers import CiTestCase
491+from cloudinit import util
492+
493+MODPATH = "cloudinit.config.cc_set_passwords."
494+
495+
496+class TestHandleSshPwauth(CiTestCase):
497+ """Test cc_set_passwords handling of ssh_pwauth in handle_ssh_pwauth."""
498+
499+ with_logs = True
500+
501+ @mock.patch(MODPATH + "util.subp")
502+ def test_unknown_value_logs_warning(self, m_subp):
503+ setpass.handle_ssh_pwauth("floo")
504+ self.assertIn("Unrecognized value: ssh_pwauth=floo",
505+ self.logs.getvalue())
506+ m_subp.assert_not_called()
507+
508+ @mock.patch(MODPATH + "update_ssh_config", return_value=True)
509+ @mock.patch(MODPATH + "util.subp")
510+ def test_systemctl_as_service_cmd(self, m_subp, m_update_ssh_config):
511+ """If systemctl in service cmd: systemctl restart name."""
512+ setpass.handle_ssh_pwauth(
513+ True, service_cmd=["systemctl"], service_name="myssh")
514+ self.assertEqual(mock.call(["systemctl", "restart", "myssh"]),
515+ m_subp.call_args)
516+
517+ @mock.patch(MODPATH + "update_ssh_config", return_value=True)
518+ @mock.patch(MODPATH + "util.subp")
519+ def test_service_as_service_cmd(self, m_subp, m_update_ssh_config):
520+ """If systemctl in service cmd: systemctl restart name."""
521+ setpass.handle_ssh_pwauth(
522+ True, service_cmd=["service"], service_name="myssh")
523+ self.assertEqual(mock.call(["service", "myssh", "restart"]),
524+ m_subp.call_args)
525+
526+ @mock.patch(MODPATH + "update_ssh_config", return_value=False)
527+ @mock.patch(MODPATH + "util.subp")
528+ def test_not_restarted_if_not_updated(self, m_subp, m_update_ssh_config):
529+ """If config is not updated, then no system restart should be done."""
530+ setpass.handle_ssh_pwauth(True)
531+ m_subp.assert_not_called()
532+ self.assertIn("No need to restart ssh", self.logs.getvalue())
533+
534+ @mock.patch(MODPATH + "update_ssh_config", return_value=True)
535+ @mock.patch(MODPATH + "util.subp")
536+ def test_unchanged_does_nothing(self, m_subp, m_update_ssh_config):
537+ """If 'unchanged', then no updates to config and no restart."""
538+ setpass.handle_ssh_pwauth(
539+ "unchanged", service_cmd=["systemctl"], service_name="myssh")
540+ m_update_ssh_config.assert_not_called()
541+ m_subp.assert_not_called()
542+
543+ @mock.patch(MODPATH + "util.subp")
544+ def test_valid_change_values(self, m_subp):
545+ """If value is a valid changen value, then update should be called."""
546+ upname = MODPATH + "update_ssh_config"
547+ optname = "PasswordAuthentication"
548+ for value in util.FALSE_STRINGS + util.TRUE_STRINGS:
549+ optval = "yes" if value in util.TRUE_STRINGS else "no"
550+ with mock.patch(upname, return_value=False) as m_update:
551+ setpass.handle_ssh_pwauth(value)
552+ m_update.assert_called_with({optname: optval})
553+ m_subp.assert_not_called()
554+
555+# vi: ts=4 expandtab
556diff --git a/cloudinit/config/tests/test_snap.py b/cloudinit/config/tests/test_snap.py
557index c5b4a9d..34c80f1 100644
558--- a/cloudinit/config/tests/test_snap.py
559+++ b/cloudinit/config/tests/test_snap.py
560@@ -9,7 +9,7 @@ from cloudinit.config.cc_snap import (
561 from cloudinit.config.schema import validate_cloudconfig_schema
562 from cloudinit import util
563 from cloudinit.tests.helpers import (
564- CiTestCase, mock, wrap_and_call, skipUnlessJsonSchema)
565+ CiTestCase, SchemaTestCaseMixin, mock, wrap_and_call, skipUnlessJsonSchema)
566
567
568 SYSTEM_USER_ASSERTION = """\
569@@ -245,9 +245,10 @@ class TestRunCommands(CiTestCase):
570
571
572 @skipUnlessJsonSchema()
573-class TestSchema(CiTestCase):
574+class TestSchema(CiTestCase, SchemaTestCaseMixin):
575
576 with_logs = True
577+ schema = schema
578
579 def test_schema_warns_on_snap_not_as_dict(self):
580 """If the snap configuration is not a dict, emit a warning."""
581@@ -340,6 +341,30 @@ class TestSchema(CiTestCase):
582 {'snap': {'assertions': {'01': 'also valid'}}}, schema)
583 self.assertEqual('', self.logs.getvalue())
584
585+ def test_duplicates_are_fine_array_array(self):
586+ """Duplicated commands array/array entries are allowed."""
587+ self.assertSchemaValid(
588+ {'commands': [["echo", "bye"], ["echo" "bye"]]},
589+ "command entries can be duplicate.")
590+
591+ def test_duplicates_are_fine_array_string(self):
592+ """Duplicated commands array/string entries are allowed."""
593+ self.assertSchemaValid(
594+ {'commands': ["echo bye", "echo bye"]},
595+ "command entries can be duplicate.")
596+
597+ def test_duplicates_are_fine_dict_array(self):
598+ """Duplicated commands dict/array entries are allowed."""
599+ self.assertSchemaValid(
600+ {'commands': {'00': ["echo", "bye"], '01': ["echo", "bye"]}},
601+ "command entries can be duplicate.")
602+
603+ def test_duplicates_are_fine_dict_string(self):
604+ """Duplicated commands dict/string entries are allowed."""
605+ self.assertSchemaValid(
606+ {'commands': {'00': "echo bye", '01': "echo bye"}},
607+ "command entries can be duplicate.")
608+
609
610 class TestHandle(CiTestCase):
611
612diff --git a/cloudinit/config/tests/test_ubuntu_advantage.py b/cloudinit/config/tests/test_ubuntu_advantage.py
613index f2a59fa..f1beeff 100644
614--- a/cloudinit/config/tests/test_ubuntu_advantage.py
615+++ b/cloudinit/config/tests/test_ubuntu_advantage.py
616@@ -7,7 +7,8 @@ from cloudinit.config.cc_ubuntu_advantage import (
617 handle, maybe_install_ua_tools, run_commands, schema)
618 from cloudinit.config.schema import validate_cloudconfig_schema
619 from cloudinit import util
620-from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
621+from cloudinit.tests.helpers import (
622+ CiTestCase, mock, SchemaTestCaseMixin, skipUnlessJsonSchema)
623
624
625 # Module path used in mocks
626@@ -105,9 +106,10 @@ class TestRunCommands(CiTestCase):
627
628
629 @skipUnlessJsonSchema()
630-class TestSchema(CiTestCase):
631+class TestSchema(CiTestCase, SchemaTestCaseMixin):
632
633 with_logs = True
634+ schema = schema
635
636 def test_schema_warns_on_ubuntu_advantage_not_as_dict(self):
637 """If ubuntu-advantage configuration is not a dict, emit a warning."""
638@@ -169,6 +171,30 @@ class TestSchema(CiTestCase):
639 {'ubuntu-advantage': {'commands': {'01': 'also valid'}}}, schema)
640 self.assertEqual('', self.logs.getvalue())
641
642+ def test_duplicates_are_fine_array_array(self):
643+ """Duplicated commands array/array entries are allowed."""
644+ self.assertSchemaValid(
645+ {'commands': [["echo", "bye"], ["echo" "bye"]]},
646+ "command entries can be duplicate.")
647+
648+ def test_duplicates_are_fine_array_string(self):
649+ """Duplicated commands array/string entries are allowed."""
650+ self.assertSchemaValid(
651+ {'commands': ["echo bye", "echo bye"]},
652+ "command entries can be duplicate.")
653+
654+ def test_duplicates_are_fine_dict_array(self):
655+ """Duplicated commands dict/array entries are allowed."""
656+ self.assertSchemaValid(
657+ {'commands': {'00': ["echo", "bye"], '01': ["echo", "bye"]}},
658+ "command entries can be duplicate.")
659+
660+ def test_duplicates_are_fine_dict_string(self):
661+ """Duplicated commands dict/string entries are allowed."""
662+ self.assertSchemaValid(
663+ {'commands': {'00': "echo bye", '01': "echo bye"}},
664+ "command entries can be duplicate.")
665+
666
667 class TestHandle(CiTestCase):
668
669diff --git a/cloudinit/distros/freebsd.py b/cloudinit/distros/freebsd.py
670index 099fac5..5b1718a 100644
671--- a/cloudinit/distros/freebsd.py
672+++ b/cloudinit/distros/freebsd.py
673@@ -113,7 +113,7 @@ class Distro(distros.Distro):
674 n = re.search(r'\d+$', dev)
675 index = n.group(0)
676
677- (out, err) = util.subp(['ifconfig', '-a'])
678+ (out, _err) = util.subp(['ifconfig', '-a'])
679 ifconfigoutput = [x for x in (out.strip()).splitlines()
680 if len(x.split()) > 0]
681 bsddev = 'NOT_FOUND'
682diff --git a/cloudinit/distros/ubuntu.py b/cloudinit/distros/ubuntu.py
683index fdc1f62..6815410 100644
684--- a/cloudinit/distros/ubuntu.py
685+++ b/cloudinit/distros/ubuntu.py
686@@ -25,7 +25,7 @@ class Distro(debian.Distro):
687 def preferred_ntp_clients(self):
688 """The preferred ntp client is dependent on the version."""
689 if not self._preferred_ntp_clients:
690- (name, version, codename) = util.system_info()['dist']
691+ (_name, _version, codename) = util.system_info()['dist']
692 # Xenial cloud-init only installed ntp, UbuntuCore has timesyncd.
693 if codename == "xenial" and not util.system_is_snappy():
694 self._preferred_ntp_clients = ['ntp']
695diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
696index f69c0ef..43226bd 100644
697--- a/cloudinit/net/__init__.py
698+++ b/cloudinit/net/__init__.py
699@@ -107,6 +107,21 @@ def is_bond(devname):
700 return os.path.exists(sys_dev_path(devname, "bonding"))
701
702
703+def is_renamed(devname):
704+ """
705+ /* interface name assignment types (sysfs name_assign_type attribute) */
706+ #define NET_NAME_UNKNOWN 0 /* unknown origin (not exposed to user) */
707+ #define NET_NAME_ENUM 1 /* enumerated by kernel */
708+ #define NET_NAME_PREDICTABLE 2 /* predictably named by the kernel */
709+ #define NET_NAME_USER 3 /* provided by user-space */
710+ #define NET_NAME_RENAMED 4 /* renamed by user-space */
711+ """
712+ name_assign_type = read_sys_net_safe(devname, 'name_assign_type')
713+ if name_assign_type and name_assign_type in ['3', '4']:
714+ return True
715+ return False
716+
717+
718 def is_vlan(devname):
719 uevent = str(read_sys_net_safe(devname, "uevent"))
720 return 'DEVTYPE=vlan' in uevent.splitlines()
721@@ -180,6 +195,17 @@ def find_fallback_nic(blacklist_drivers=None):
722 if not blacklist_drivers:
723 blacklist_drivers = []
724
725+ if 'net.ifnames=0' in util.get_cmdline():
726+ LOG.debug('Stable ifnames disabled by net.ifnames=0 in /proc/cmdline')
727+ else:
728+ unstable = [device for device in get_devicelist()
729+ if device != 'lo' and not is_renamed(device)]
730+ if len(unstable):
731+ LOG.debug('Found unstable nic names: %s; calling udevadm settle',
732+ unstable)
733+ msg = 'Waiting for udev events to settle'
734+ util.log_time(LOG.debug, msg, func=util.udevadm_settle)
735+
736 # get list of interfaces that could have connections
737 invalid_interfaces = set(['lo'])
738 potential_interfaces = set([device for device in get_devicelist()
739@@ -295,7 +321,7 @@ def apply_network_config_names(netcfg, strict_present=True, strict_busy=True):
740
741 def _version_2(netcfg):
742 renames = []
743- for key, ent in netcfg.get('ethernets', {}).items():
744+ for ent in netcfg.get('ethernets', {}).values():
745 # only rename if configured to do so
746 name = ent.get('set-name')
747 if not name:
748diff --git a/cloudinit/net/cmdline.py b/cloudinit/net/cmdline.py
749index 9e9fe0f..f89a0f7 100755
750--- a/cloudinit/net/cmdline.py
751+++ b/cloudinit/net/cmdline.py
752@@ -65,7 +65,7 @@ def _klibc_to_config_entry(content, mac_addrs=None):
753 iface['mac_address'] = mac_addrs[name]
754
755 # Handle both IPv4 and IPv6 values
756- for v, pre in (('ipv4', 'IPV4'), ('ipv6', 'IPV6')):
757+ for pre in ('IPV4', 'IPV6'):
758 # if no IPV4ADDR or IPV6ADDR, then go on.
759 if pre + "ADDR" not in data:
760 continue
761diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py
762index 087c0c0..12cf509 100644
763--- a/cloudinit/net/dhcp.py
764+++ b/cloudinit/net/dhcp.py
765@@ -216,7 +216,7 @@ def networkd_get_option_from_leases(keyname, leases_d=None):
766 if leases_d is None:
767 leases_d = NETWORKD_LEASES_DIR
768 leases = networkd_load_leases(leases_d=leases_d)
769- for ifindex, data in sorted(leases.items()):
770+ for _ifindex, data in sorted(leases.items()):
771 if data.get(keyname):
772 return data[keyname]
773 return None
774diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
775index 39d89c4..e53b9f1 100644
776--- a/cloudinit/net/sysconfig.py
777+++ b/cloudinit/net/sysconfig.py
778@@ -287,7 +287,6 @@ class Renderer(renderer.Renderer):
779 if subnet_type == 'dhcp6':
780 iface_cfg['IPV6INIT'] = True
781 iface_cfg['DHCPV6C'] = True
782- iface_cfg['BOOTPROTO'] = 'dhcp'
783 elif subnet_type in ['dhcp4', 'dhcp']:
784 iface_cfg['BOOTPROTO'] = 'dhcp'
785 elif subnet_type == 'static':
786@@ -364,7 +363,7 @@ class Renderer(renderer.Renderer):
787
788 @classmethod
789 def _render_subnet_routes(cls, iface_cfg, route_cfg, subnets):
790- for i, subnet in enumerate(subnets, start=len(iface_cfg.children)):
791+ for _, subnet in enumerate(subnets, start=len(iface_cfg.children)):
792 for route in subnet.get('routes', []):
793 is_ipv6 = subnet.get('ipv6') or is_ipv6_addr(route['gateway'])
794
795diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
796index 276556e..5c017d1 100644
797--- a/cloudinit/net/tests/test_init.py
798+++ b/cloudinit/net/tests/test_init.py
799@@ -199,6 +199,7 @@ class TestGenerateFallbackConfig(CiTestCase):
800 self.sysdir = self.tmp_dir() + '/'
801 self.m_sys_path.return_value = self.sysdir
802 self.addCleanup(sys_mock.stop)
803+ self.add_patch('cloudinit.net.util.udevadm_settle', 'm_settle')
804
805 def test_generate_fallback_finds_connected_eth_with_mac(self):
806 """generate_fallback_config finds any connected device with a mac."""
807diff --git a/cloudinit/reporting/events.py b/cloudinit/reporting/events.py
808index 4f62d2f..e5dfab3 100644
809--- a/cloudinit/reporting/events.py
810+++ b/cloudinit/reporting/events.py
811@@ -192,7 +192,7 @@ class ReportEventStack(object):
812
813 def _childrens_finish_info(self):
814 for cand_result in (status.FAIL, status.WARN):
815- for name, (value, msg) in self.children.items():
816+ for _name, (value, _msg) in self.children.items():
817 if value == cand_result:
818 return (value, self.message)
819 return (self.result, self.message)
820diff --git a/cloudinit/sources/DataSourceAliYun.py b/cloudinit/sources/DataSourceAliYun.py
821index 22279d0..858e082 100644
822--- a/cloudinit/sources/DataSourceAliYun.py
823+++ b/cloudinit/sources/DataSourceAliYun.py
824@@ -45,7 +45,7 @@ def _is_aliyun():
825
826 def parse_public_keys(public_keys):
827 keys = []
828- for key_id, key_body in public_keys.items():
829+ for _key_id, key_body in public_keys.items():
830 if isinstance(key_body, str):
831 keys.append(key_body.strip())
832 elif isinstance(key_body, list):
833diff --git a/cloudinit/sources/DataSourceAltCloud.py b/cloudinit/sources/DataSourceAltCloud.py
834index e1d0055..f6e86f3 100644
835--- a/cloudinit/sources/DataSourceAltCloud.py
836+++ b/cloudinit/sources/DataSourceAltCloud.py
837@@ -29,7 +29,6 @@ CLOUD_INFO_FILE = '/etc/sysconfig/cloud-info'
838
839 # Shell command lists
840 CMD_PROBE_FLOPPY = ['modprobe', 'floppy']
841-CMD_UDEVADM_SETTLE = ['udevadm', 'settle', '--timeout=5']
842
843 META_DATA_NOT_SUPPORTED = {
844 'block-device-mapping': {},
845@@ -196,9 +195,7 @@ class DataSourceAltCloud(sources.DataSource):
846
847 # udevadm settle for floppy device
848 try:
849- cmd = CMD_UDEVADM_SETTLE
850- cmd.append('--exit-if-exists=' + floppy_dev)
851- (cmd_out, _err) = util.subp(cmd)
852+ (cmd_out, _err) = util.udevadm_settle(exists=floppy_dev, timeout=5)
853 LOG.debug('Command: %s\nOutput%s', ' '.join(cmd), cmd_out)
854 except ProcessExecutionError as _err:
855 util.logexc(LOG, 'Failed command: %s\n%s', ' '.join(cmd), _err)
856diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
857index 0ee622e..a71197a 100644
858--- a/cloudinit/sources/DataSourceAzure.py
859+++ b/cloudinit/sources/DataSourceAzure.py
860@@ -107,31 +107,24 @@ def find_dev_from_busdev(camcontrol_out, busdev):
861 return None
862
863
864-def get_dev_storvsc_sysctl():
865+def execute_or_debug(cmd, fail_ret=None):
866 try:
867- sysctl_out, err = util.subp(['sysctl', 'dev.storvsc'])
868+ return util.subp(cmd)[0]
869 except util.ProcessExecutionError:
870- LOG.debug("Fail to execute sysctl dev.storvsc")
871- sysctl_out = ""
872- return sysctl_out
873+ LOG.debug("Failed to execute: %s", ' '.join(cmd))
874+ return fail_ret
875+
876+
877+def get_dev_storvsc_sysctl():
878+ return execute_or_debug(["sysctl", "dev.storvsc"], fail_ret="")
879
880
881 def get_camcontrol_dev_bus():
882- try:
883- camcontrol_b_out, err = util.subp(['camcontrol', 'devlist', '-b'])
884- except util.ProcessExecutionError:
885- LOG.debug("Fail to execute camcontrol devlist -b")
886- return None
887- return camcontrol_b_out
888+ return execute_or_debug(['camcontrol', 'devlist', '-b'])
889
890
891 def get_camcontrol_dev():
892- try:
893- camcontrol_out, err = util.subp(['camcontrol', 'devlist'])
894- except util.ProcessExecutionError:
895- LOG.debug("Fail to execute camcontrol devlist")
896- return None
897- return camcontrol_out
898+ return execute_or_debug(['camcontrol', 'devlist'])
899
900
901 def get_resource_disk_on_freebsd(port_id):
902@@ -474,7 +467,7 @@ class DataSourceAzure(sources.DataSource):
903 before we go into our polling loop."""
904 try:
905 get_metadata_from_fabric(None, lease['unknown-245'])
906- except Exception as exc:
907+ except Exception:
908 LOG.warning(
909 "Error communicating with Azure fabric; You may experience."
910 "connectivity issues.", exc_info=True)
911@@ -492,7 +485,7 @@ class DataSourceAzure(sources.DataSource):
912 jump back into the polling loop in order to retrieve the ovf_env."""
913 if not ret:
914 return False
915- (md, self.userdata_raw, cfg, files) = ret
916+ (_md, self.userdata_raw, cfg, _files) = ret
917 path = REPROVISION_MARKER_FILE
918 if (cfg.get('PreprovisionedVm') is True or
919 os.path.isfile(path)):
920@@ -528,7 +521,7 @@ class DataSourceAzure(sources.DataSource):
921 self.ds_cfg['agent_command'])
922 try:
923 fabric_data = metadata_func()
924- except Exception as exc:
925+ except Exception:
926 LOG.warning(
927 "Error communicating with Azure fabric; You may experience."
928 "connectivity issues.", exc_info=True)
929diff --git a/cloudinit/sources/DataSourceIBMCloud.py b/cloudinit/sources/DataSourceIBMCloud.py
930index 02b3d56..01106ec 100644
931--- a/cloudinit/sources/DataSourceIBMCloud.py
932+++ b/cloudinit/sources/DataSourceIBMCloud.py
933@@ -8,17 +8,11 @@ There are 2 different api exposed launch methods.
934 * template: This is the legacy method of launching instances.
935 When booting from an image template, the system boots first into
936 a "provisioning" mode. There, host <-> guest mechanisms are utilized
937- to execute code in the guest and provision it.
938+ to execute code in the guest and configure it. The configuration
939+ includes configuring the system network and possibly installing
940+ packages and other software stack.
941
942- Cloud-init will disable itself when it detects that it is in the
943- provisioning mode. It detects this by the presence of
944- a file '/root/provisioningConfiguration.cfg'.
945-
946- When provided with user-data, the "first boot" will contain a
947- ConfigDrive-like disk labeled with 'METADATA'. If there is no user-data
948- provided, then there is no data-source.
949-
950- Cloud-init never does any network configuration in this mode.
951+ After the provisioning is finished, the system reboots.
952
953 * os_code: Essentially "launch by OS Code" (Operating System Code).
954 This is a more modern approach. There is no specific "provisioning" boot.
955@@ -30,11 +24,73 @@ There are 2 different api exposed launch methods.
956 mean that 1 in 8^16 (~4 billion) Xen ConfigDrive systems will be
957 incorrectly identified as IBMCloud.
958
959+The combination of these 2 launch methods and with or without user-data
960+creates 6 boot scenarios.
961+ A. os_code with user-data
962+ B. os_code without user-data
963+ Cloud-init is fully operational in this mode.
964+
965+ There is a block device attached with label 'config-2'.
966+ As it differs from OpenStack's config-2, we have to differentiate.
967+ We do so by requiring the UUID on the filesystem to be "9796-932E".
968+
969+ This disk will have the following files. Specifically note, there
970+ is no versioned path to the meta-data, only 'latest':
971+ openstack/latest/meta_data.json
972+ openstack/latest/network_data.json
973+ openstack/latest/user_data [optional]
974+ openstack/latest/vendor_data.json
975+
976+ vendor_data.json as of 2018-04 looks like this:
977+ {"cloud-init":"#!/bin/bash\necho 'root:$6$<snip>' | chpasswd -e"}
978+
979+ The only difference between A and B in this mode is the presence
980+ of user_data on the config disk.
981+
982+ C. template, provisioning boot with user-data
983+ D. template, provisioning boot without user-data.
984+ With ds-identify cloud-init is fully disabled in this mode.
985+ Without ds-identify, cloud-init None datasource will be used.
986+
987+ This is currently identified by the presence of
988+ /root/provisioningConfiguration.cfg . That file is placed into the
989+ system before it is booted.
990+
991+ The difference between C and D is the presence of the METADATA disk
992+ as described in E below. There is no METADATA disk attached unless
993+ user-data is provided.
994+
995+ E. template, post-provisioning boot with user-data.
996+ Cloud-init is fully operational in this mode.
997+
998+ This is identified by a block device with filesystem label "METADATA".
999+ The looks similar to a version-1 OpenStack config drive. It will
1000+ have the following files:
1001+
1002+ openstack/latest/user_data
1003+ openstack/latest/meta_data.json
1004+ openstack/content/interfaces
1005+ meta.js
1006+
1007+ meta.js contains something similar to user_data. cloud-init ignores it.
1008+ cloud-init ignores the 'interfaces' style file here.
1009+ In this mode, cloud-init has networking code disabled. It relies
1010+ on the provisioning boot to have configured networking.
1011+
1012+ F. template, post-provisioning boot without user-data.
1013+ With ds-identify, cloud-init will be fully disabled.
1014+ Without ds-identify, cloud-init None datasource will be used.
1015+
1016+ There is no information available to identify this scenario.
1017+
1018+ The user will be able to ssh in as as root with their public keys that
1019+ have been installed into /root/ssh/.authorized_keys
1020+ during the provisioning stage.
1021+
1022 TODO:
1023 * is uuid (/sys/hypervisor/uuid) stable for life of an instance?
1024 it seems it is not the same as data's uuid in the os_code case
1025 but is in the template case.
1026-
1027 """
1028 import base64
1029 import json
1030@@ -138,8 +194,30 @@ def _is_xen():
1031 return os.path.exists("/proc/xen")
1032
1033
1034-def _is_ibm_provisioning():
1035- return os.path.exists("/root/provisioningConfiguration.cfg")
1036+def _is_ibm_provisioning(
1037+ prov_cfg="/root/provisioningConfiguration.cfg",
1038+ inst_log="/root/swinstall.log",
1039+ boot_ref="/proc/1/environ"):
1040+ """Return boolean indicating if this boot is ibm provisioning boot."""
1041+ if os.path.exists(prov_cfg):
1042+ msg = "config '%s' exists." % prov_cfg
1043+ result = True
1044+ if os.path.exists(inst_log):
1045+ if os.path.exists(boot_ref):
1046+ result = (os.stat(inst_log).st_mtime >
1047+ os.stat(boot_ref).st_mtime)
1048+ msg += (" log '%s' from %s boot." %
1049+ (inst_log, "current" if result else "previous"))
1050+ else:
1051+ msg += (" log '%s' existed, but no reference file '%s'." %
1052+ (inst_log, boot_ref))
1053+ result = False
1054+ else:
1055+ msg += " log '%s' did not exist." % inst_log
1056+ else:
1057+ result, msg = (False, "config '%s' did not exist." % prov_cfg)
1058+ LOG.debug("ibm_provisioning=%s: %s", result, msg)
1059+ return result
1060
1061
1062 def get_ibm_platform():
1063@@ -189,7 +267,7 @@ def get_ibm_platform():
1064 else:
1065 return (Platforms.TEMPLATE_LIVE_METADATA, metadata_path)
1066 elif _is_ibm_provisioning():
1067- return (Platforms.TEMPLATE_PROVISIONING_NODATA, None)
1068+ return (Platforms.TEMPLATE_PROVISIONING_NODATA, None)
1069 return not_found
1070
1071
1072diff --git a/cloudinit/sources/DataSourceMAAS.py b/cloudinit/sources/DataSourceMAAS.py
1073index 6ac8863..aa56add 100644
1074--- a/cloudinit/sources/DataSourceMAAS.py
1075+++ b/cloudinit/sources/DataSourceMAAS.py
1076@@ -204,7 +204,7 @@ def read_maas_seed_url(seed_url, read_file_or_url=None, timeout=None,
1077 seed_url = seed_url[:-1]
1078
1079 md = {}
1080- for path, dictname, binary, optional in DS_FIELDS:
1081+ for path, _dictname, binary, optional in DS_FIELDS:
1082 if version is None:
1083 url = "%s/%s" % (seed_url, path)
1084 else:
1085diff --git a/cloudinit/sources/DataSourceOVF.py b/cloudinit/sources/DataSourceOVF.py
1086index dc914a7..178ccb0 100644
1087--- a/cloudinit/sources/DataSourceOVF.py
1088+++ b/cloudinit/sources/DataSourceOVF.py
1089@@ -556,7 +556,7 @@ def search_file(dirpath, filename):
1090 if not dirpath or not filename:
1091 return None
1092
1093- for root, dirs, files in os.walk(dirpath):
1094+ for root, _dirs, files in os.walk(dirpath):
1095 if filename in files:
1096 return os.path.join(root, filename)
1097
1098diff --git a/cloudinit/sources/DataSourceOpenStack.py b/cloudinit/sources/DataSourceOpenStack.py
1099index e55a763..fb166ae 100644
1100--- a/cloudinit/sources/DataSourceOpenStack.py
1101+++ b/cloudinit/sources/DataSourceOpenStack.py
1102@@ -86,7 +86,7 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
1103 md_urls.append(md_url)
1104 url2base[md_url] = url
1105
1106- (max_wait, timeout, retries) = self._get_url_settings()
1107+ (max_wait, timeout, _retries) = self._get_url_settings()
1108 start_time = time.time()
1109 avail_url = url_helper.wait_for_url(urls=md_urls, max_wait=max_wait,
1110 timeout=timeout)
1111@@ -106,7 +106,7 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
1112 except IOError:
1113 return False
1114
1115- (max_wait, timeout, retries) = self._get_url_settings()
1116+ (_max_wait, timeout, retries) = self._get_url_settings()
1117
1118 try:
1119 results = util.log_time(LOG.debug,
1120diff --git a/cloudinit/sources/DataSourceSmartOS.py b/cloudinit/sources/DataSourceSmartOS.py
1121index c8998b4..4ea00eb 100644
1122--- a/cloudinit/sources/DataSourceSmartOS.py
1123+++ b/cloudinit/sources/DataSourceSmartOS.py
1124@@ -11,7 +11,7 @@
1125 # SmartOS hosts use a serial console (/dev/ttyS1) on KVM Linux Guests
1126 # The meta-data is transmitted via key/value pairs made by
1127 # requests on the console. For example, to get the hostname, you
1128-# would send "GET hostname" on /dev/ttyS1.
1129+# would send "GET sdc:hostname" on /dev/ttyS1.
1130 # For Linux Guests running in LX-Brand Zones on SmartOS hosts
1131 # a socket (/native/.zonecontrol/metadata.sock) is used instead
1132 # of a serial console.
1133@@ -23,6 +23,7 @@
1134 import base64
1135 import binascii
1136 import errno
1137+import fcntl
1138 import json
1139 import os
1140 import random
1141@@ -273,8 +274,14 @@ class DataSourceSmartOS(sources.DataSource):
1142 write_boot_content(u_data, u_data_f)
1143
1144 # Handle the cloud-init regular meta
1145+
1146+ # The hostname may or may not be qualified with the local domain name.
1147+ # This follows section 3.14 of RFC 2132.
1148 if not md['local-hostname']:
1149- md['local-hostname'] = md['instance-id']
1150+ if md['hostname']:
1151+ md['local-hostname'] = md['hostname']
1152+ else:
1153+ md['local-hostname'] = md['instance-id']
1154
1155 ud = None
1156 if md['user-data']:
1157@@ -455,9 +462,9 @@ class JoyentMetadataClient(object):
1158
1159 def list(self):
1160 result = self.request(rtype='KEYS')
1161- if result:
1162- result = result.split('\n')
1163- return result
1164+ if not result:
1165+ return []
1166+ return result.split('\n')
1167
1168 def put(self, key, val):
1169 param = b' '.join([base64.b64encode(i.encode())
1170@@ -520,6 +527,7 @@ class JoyentMetadataSerialClient(JoyentMetadataClient):
1171 if not ser.isOpen():
1172 raise SystemError("Unable to open %s" % self.device)
1173 self.fp = ser
1174+ fcntl.lockf(ser, fcntl.LOCK_EX)
1175 self._flush()
1176 self._negotiate()
1177
1178diff --git a/cloudinit/sources/helpers/digitalocean.py b/cloudinit/sources/helpers/digitalocean.py
1179index 693f8d5..0e7ccca 100644
1180--- a/cloudinit/sources/helpers/digitalocean.py
1181+++ b/cloudinit/sources/helpers/digitalocean.py
1182@@ -41,10 +41,9 @@ def assign_ipv4_link_local(nic=None):
1183 "address")
1184
1185 try:
1186- (result, _err) = util.subp(ip_addr_cmd)
1187+ util.subp(ip_addr_cmd)
1188 LOG.debug("assigned ip4LL address '%s' to '%s'", addr, nic)
1189-
1190- (result, _err) = util.subp(ip_link_cmd)
1191+ util.subp(ip_link_cmd)
1192 LOG.debug("brought device '%s' up", nic)
1193 except Exception:
1194 util.logexc(LOG, "ip4LL address assignment of '%s' to '%s' failed."
1195@@ -75,7 +74,7 @@ def del_ipv4_link_local(nic=None):
1196 ip_addr_cmd = ['ip', 'addr', 'flush', 'dev', nic]
1197
1198 try:
1199- (result, _err) = util.subp(ip_addr_cmd)
1200+ util.subp(ip_addr_cmd)
1201 LOG.debug("removed ip4LL addresses from %s", nic)
1202
1203 except Exception as e:
1204diff --git a/cloudinit/sources/helpers/openstack.py b/cloudinit/sources/helpers/openstack.py
1205index 26f3168..a4cf066 100644
1206--- a/cloudinit/sources/helpers/openstack.py
1207+++ b/cloudinit/sources/helpers/openstack.py
1208@@ -638,7 +638,7 @@ def convert_net_json(network_json=None, known_macs=None):
1209 known_macs = net.get_interfaces_by_mac()
1210
1211 # go through and fill out the link_id_info with names
1212- for link_id, info in link_id_info.items():
1213+ for _link_id, info in link_id_info.items():
1214 if info.get('name'):
1215 continue
1216 if info.get('mac') in known_macs:
1217diff --git a/cloudinit/sources/helpers/vmware/imc/config_nic.py b/cloudinit/sources/helpers/vmware/imc/config_nic.py
1218index 2d8900e..3ef8c62 100644
1219--- a/cloudinit/sources/helpers/vmware/imc/config_nic.py
1220+++ b/cloudinit/sources/helpers/vmware/imc/config_nic.py
1221@@ -73,7 +73,7 @@ class NicConfigurator(object):
1222 The mac address(es) are in the lower case
1223 """
1224 cmd = ['ip', 'addr', 'show']
1225- (output, err) = util.subp(cmd)
1226+ output, _err = util.subp(cmd)
1227 sections = re.split(r'\n\d+: ', '\n' + output)[1:]
1228
1229 macPat = r'link/ether (([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2}))'
1230diff --git a/cloudinit/sources/helpers/vmware/imc/config_passwd.py b/cloudinit/sources/helpers/vmware/imc/config_passwd.py
1231index 75cfbaa..8c91fa4 100644
1232--- a/cloudinit/sources/helpers/vmware/imc/config_passwd.py
1233+++ b/cloudinit/sources/helpers/vmware/imc/config_passwd.py
1234@@ -56,10 +56,10 @@ class PasswordConfigurator(object):
1235 LOG.info('Expiring password.')
1236 for user in uidUserList:
1237 try:
1238- out, err = util.subp(['passwd', '--expire', user])
1239+ util.subp(['passwd', '--expire', user])
1240 except util.ProcessExecutionError as e:
1241 if os.path.exists('/usr/bin/chage'):
1242- out, e = util.subp(['chage', '-d', '0', user])
1243+ util.subp(['chage', '-d', '0', user])
1244 else:
1245 LOG.warning('Failed to expire password for %s with error: '
1246 '%s', user, e)
1247diff --git a/cloudinit/sources/helpers/vmware/imc/guestcust_util.py b/cloudinit/sources/helpers/vmware/imc/guestcust_util.py
1248index 4407525..a590f32 100644
1249--- a/cloudinit/sources/helpers/vmware/imc/guestcust_util.py
1250+++ b/cloudinit/sources/helpers/vmware/imc/guestcust_util.py
1251@@ -91,7 +91,7 @@ def enable_nics(nics):
1252
1253 for attempt in range(0, enableNicsWaitRetries):
1254 logger.debug("Trying to connect interfaces, attempt %d", attempt)
1255- (out, err) = set_customization_status(
1256+ (out, _err) = set_customization_status(
1257 GuestCustStateEnum.GUESTCUST_STATE_RUNNING,
1258 GuestCustEventEnum.GUESTCUST_EVENT_ENABLE_NICS,
1259 nics)
1260@@ -104,7 +104,7 @@ def enable_nics(nics):
1261 return
1262
1263 for count in range(0, enableNicsWaitCount):
1264- (out, err) = set_customization_status(
1265+ (out, _err) = set_customization_status(
1266 GuestCustStateEnum.GUESTCUST_STATE_RUNNING,
1267 GuestCustEventEnum.GUESTCUST_EVENT_QUERY_NICS,
1268 nics)
1269diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
1270index e7fda22..452e921 100644
1271--- a/cloudinit/sources/tests/test_init.py
1272+++ b/cloudinit/sources/tests/test_init.py
1273@@ -278,7 +278,7 @@ class TestDataSource(CiTestCase):
1274 base_args = get_args(DataSource.get_hostname) # pylint: disable=W1505
1275 # Import all DataSource subclasses so we can inspect them.
1276 modules = util.find_modules(os.path.dirname(os.path.dirname(__file__)))
1277- for loc, name in modules.items():
1278+ for _loc, name in modules.items():
1279 mod_locs, _ = importer.find_module(name, ['cloudinit.sources'], [])
1280 if mod_locs:
1281 importer.import_module(mod_locs[0])
1282diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py
1283index 882517f..73c3177 100644
1284--- a/cloudinit/ssh_util.py
1285+++ b/cloudinit/ssh_util.py
1286@@ -279,24 +279,28 @@ class SshdConfigLine(object):
1287
1288
1289 def parse_ssh_config(fname):
1290+ if not os.path.isfile(fname):
1291+ return []
1292+ return parse_ssh_config_lines(util.load_file(fname).splitlines())
1293+
1294+
1295+def parse_ssh_config_lines(lines):
1296 # See: man sshd_config
1297 # The file contains keyword-argument pairs, one per line.
1298 # Lines starting with '#' and empty lines are interpreted as comments.
1299 # Note: key-words are case-insensitive and arguments are case-sensitive
1300- lines = []
1301- if not os.path.isfile(fname):
1302- return lines
1303- for line in util.load_file(fname).splitlines():
1304+ ret = []
1305+ for line in lines:
1306 line = line.strip()
1307 if not line or line.startswith("#"):
1308- lines.append(SshdConfigLine(line))
1309+ ret.append(SshdConfigLine(line))
1310 continue
1311 try:
1312 key, val = line.split(None, 1)
1313 except ValueError:
1314 key, val = line.split('=', 1)
1315- lines.append(SshdConfigLine(line, key, val))
1316- return lines
1317+ ret.append(SshdConfigLine(line, key, val))
1318+ return ret
1319
1320
1321 def parse_ssh_config_map(fname):
1322@@ -310,4 +314,56 @@ def parse_ssh_config_map(fname):
1323 ret[line.key] = line.value
1324 return ret
1325
1326+
1327+def update_ssh_config(updates, fname=DEF_SSHD_CFG):
1328+ """Read fname, and update if changes are necessary.
1329+
1330+ @param updates: dictionary of desired values {Option: value}
1331+ @return: boolean indicating if an update was done."""
1332+ lines = parse_ssh_config(fname)
1333+ changed = update_ssh_config_lines(lines=lines, updates=updates)
1334+ if changed:
1335+ util.write_file(
1336+ fname, "\n".join([str(l) for l in lines]) + "\n", copy_mode=True)
1337+ return len(changed) != 0
1338+
1339+
1340+def update_ssh_config_lines(lines, updates):
1341+ """Update the ssh config lines per updates.
1342+
1343+ @param lines: array of SshdConfigLine. This array is updated in place.
1344+ @param updates: dictionary of desired values {Option: value}
1345+ @return: A list of keys in updates that were changed."""
1346+ found = set()
1347+ changed = []
1348+
1349+ # Keywords are case-insensitive and arguments are case-sensitive
1350+ casemap = dict([(k.lower(), k) for k in updates.keys()])
1351+
1352+ for (i, line) in enumerate(lines, start=1):
1353+ if not line.key:
1354+ continue
1355+ if line.key in casemap:
1356+ key = casemap[line.key]
1357+ value = updates[key]
1358+ found.add(key)
1359+ if line.value == value:
1360+ LOG.debug("line %d: option %s already set to %s",
1361+ i, key, value)
1362+ else:
1363+ changed.append(key)
1364+ LOG.debug("line %d: option %s updated %s -> %s", i,
1365+ key, line.value, value)
1366+ line.value = value
1367+
1368+ if len(found) != len(updates):
1369+ for key, value in updates.items():
1370+ if key in found:
1371+ continue
1372+ changed.append(key)
1373+ lines.append(SshdConfigLine('', key, value))
1374+ LOG.debug("line %d: option %s added with %s",
1375+ len(lines), key, value)
1376+ return changed
1377+
1378 # vi: ts=4 expandtab
1379diff --git a/cloudinit/templater.py b/cloudinit/templater.py
1380index 9a087e1..7e7acb8 100644
1381--- a/cloudinit/templater.py
1382+++ b/cloudinit/templater.py
1383@@ -147,7 +147,7 @@ def render_string(content, params):
1384 Warning: py2 str with non-ascii chars will cause UnicodeDecodeError."""
1385 if not params:
1386 params = {}
1387- template_type, renderer, content = detect_template(content)
1388+ _template_type, renderer, content = detect_template(content)
1389 return renderer(content, params)
1390
1391 # vi: ts=4 expandtab
1392diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
1393index 82fd347..117a9cf 100644
1394--- a/cloudinit/tests/helpers.py
1395+++ b/cloudinit/tests/helpers.py
1396@@ -8,6 +8,7 @@ import os
1397 import shutil
1398 import sys
1399 import tempfile
1400+import time
1401 import unittest
1402
1403 import mock
1404@@ -24,6 +25,8 @@ try:
1405 except ImportError:
1406 from ConfigParser import ConfigParser
1407
1408+from cloudinit.config.schema import (
1409+ SchemaValidationError, validate_cloudconfig_schema)
1410 from cloudinit import helpers as ch
1411 from cloudinit import util
1412
1413@@ -261,7 +264,8 @@ class FilesystemMockingTestCase(ResourceUsingTestCase):
1414 os.path: [('isfile', 1), ('exists', 1),
1415 ('islink', 1), ('isdir', 1), ('lexists', 1)],
1416 os: [('listdir', 1), ('mkdir', 1),
1417- ('lstat', 1), ('symlink', 2)]
1418+ ('lstat', 1), ('symlink', 2),
1419+ ('stat', 1)]
1420 }
1421
1422 if hasattr(os, 'scandir'):
1423@@ -312,6 +316,23 @@ class HttprettyTestCase(CiTestCase):
1424 super(HttprettyTestCase, self).tearDown()
1425
1426
1427+class SchemaTestCaseMixin(unittest2.TestCase):
1428+
1429+ def assertSchemaValid(self, cfg, msg="Valid Schema failed validation."):
1430+ """Assert the config is valid per self.schema.
1431+
1432+ If there is only one top level key in the schema properties, then
1433+ the cfg will be put under that key."""
1434+ props = list(self.schema.get('properties'))
1435+ # put cfg under top level key if there is only one in the schema
1436+ if len(props) == 1:
1437+ cfg = {props[0]: cfg}
1438+ try:
1439+ validate_cloudconfig_schema(cfg, self.schema, strict=True)
1440+ except SchemaValidationError:
1441+ self.fail(msg)
1442+
1443+
1444 def populate_dir(path, files):
1445 if not os.path.exists(path):
1446 os.makedirs(path)
1447@@ -330,11 +351,20 @@ def populate_dir(path, files):
1448 return ret
1449
1450
1451+def populate_dir_with_ts(path, data):
1452+ """data is {'file': ('contents', mtime)}. mtime relative to now."""
1453+ populate_dir(path, dict((k, v[0]) for k, v in data.items()))
1454+ btime = time.time()
1455+ for fpath, (_contents, mtime) in data.items():
1456+ ts = btime + mtime if mtime else btime
1457+ os.utime(os.path.sep.join((path, fpath)), (ts, ts))
1458+
1459+
1460 def dir2dict(startdir, prefix=None):
1461 flist = {}
1462 if prefix is None:
1463 prefix = startdir
1464- for root, dirs, files in os.walk(startdir):
1465+ for root, _dirs, files in os.walk(startdir):
1466 for fname in files:
1467 fpath = os.path.join(root, fname)
1468 key = fpath[len(prefix):]
1469diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py
1470index 3f37dbb..3c05a43 100644
1471--- a/cloudinit/tests/test_util.py
1472+++ b/cloudinit/tests/test_util.py
1473@@ -135,7 +135,7 @@ class TestGetHostnameFqdn(CiTestCase):
1474 def test_get_hostname_fqdn_from_passes_metadata_only_to_cloud(self):
1475 """Calls to cloud.get_hostname pass the metadata_only parameter."""
1476 mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
1477- hostname, fqdn = util.get_hostname_fqdn(
1478+ _hn, _fqdn = util.get_hostname_fqdn(
1479 cfg={}, cloud=mycloud, metadata_only=True)
1480 self.assertEqual(
1481 [{'fqdn': True, 'metadata_only': True},
1482@@ -212,4 +212,53 @@ class TestBlkid(CiTestCase):
1483 capture=True, decode="replace")
1484
1485
1486+@mock.patch('cloudinit.util.subp')
1487+class TestUdevadmSettle(CiTestCase):
1488+ def test_with_no_params(self, m_subp):
1489+ """called with no parameters."""
1490+ util.udevadm_settle()
1491+ m_subp.called_once_with(mock.call(['udevadm', 'settle']))
1492+
1493+ def test_with_exists_and_not_exists(self, m_subp):
1494+ """with exists=file where file does not exist should invoke subp."""
1495+ mydev = self.tmp_path("mydev")
1496+ util.udevadm_settle(exists=mydev)
1497+ m_subp.called_once_with(
1498+ ['udevadm', 'settle', '--exit-if-exists=%s' % mydev])
1499+
1500+ def test_with_exists_and_file_exists(self, m_subp):
1501+ """with exists=file where file does exist should not invoke subp."""
1502+ mydev = self.tmp_path("mydev")
1503+ util.write_file(mydev, "foo\n")
1504+ util.udevadm_settle(exists=mydev)
1505+ self.assertIsNone(m_subp.call_args)
1506+
1507+ def test_with_timeout_int(self, m_subp):
1508+ """timeout can be an integer."""
1509+ timeout = 9
1510+ util.udevadm_settle(timeout=timeout)
1511+ m_subp.called_once_with(
1512+ ['udevadm', 'settle', '--timeout=%s' % timeout])
1513+
1514+ def test_with_timeout_string(self, m_subp):
1515+ """timeout can be a string."""
1516+ timeout = "555"
1517+ util.udevadm_settle(timeout=timeout)
1518+ m_subp.assert_called_once_with(
1519+ ['udevadm', 'settle', '--timeout=%s' % timeout])
1520+
1521+ def test_with_exists_and_timeout(self, m_subp):
1522+ """test call with both exists and timeout."""
1523+ mydev = self.tmp_path("mydev")
1524+ timeout = "3"
1525+ util.udevadm_settle(exists=mydev)
1526+ m_subp.called_once_with(
1527+ ['udevadm', 'settle', '--exit-if-exists=%s' % mydev,
1528+ '--timeout=%s' % timeout])
1529+
1530+ def test_subp_exception_raises_to_caller(self, m_subp):
1531+ m_subp.side_effect = util.ProcessExecutionError("BOOM")
1532+ self.assertRaises(util.ProcessExecutionError, util.udevadm_settle)
1533+
1534+
1535 # vi: ts=4 expandtab
1536diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
1537index 03a573a..1de07b1 100644
1538--- a/cloudinit/url_helper.py
1539+++ b/cloudinit/url_helper.py
1540@@ -519,7 +519,7 @@ def oauth_headers(url, consumer_key, token_key, token_secret, consumer_secret,
1541 resource_owner_secret=token_secret,
1542 signature_method=oauth1.SIGNATURE_PLAINTEXT,
1543 timestamp=timestamp)
1544- uri, signed_headers, body = client.sign(url)
1545+ _uri, signed_headers, _body = client.sign(url)
1546 return signed_headers
1547
1548 # vi: ts=4 expandtab
1549diff --git a/cloudinit/util.py b/cloudinit/util.py
1550index 1717b52..2828ca3 100644
1551--- a/cloudinit/util.py
1552+++ b/cloudinit/util.py
1553@@ -2214,7 +2214,7 @@ def parse_mtab(path):
1554 def find_freebsd_part(label_part):
1555 if label_part.startswith("/dev/label/"):
1556 target_label = label_part[5:]
1557- (label_part, err) = subp(['glabel', 'status', '-s'])
1558+ (label_part, _err) = subp(['glabel', 'status', '-s'])
1559 for labels in label_part.split("\n"):
1560 items = labels.split()
1561 if len(items) > 0 and items[0].startswith(target_label):
1562@@ -2727,4 +2727,19 @@ def mount_is_read_write(mount_point):
1563 mount_opts = result[-1].split(',')
1564 return mount_opts[0] == 'rw'
1565
1566+
1567+def udevadm_settle(exists=None, timeout=None):
1568+ """Invoke udevadm settle with optional exists and timeout parameters"""
1569+ settle_cmd = ["udevadm", "settle"]
1570+ if exists:
1571+ # skip the settle if the requested path already exists
1572+ if os.path.exists(exists):
1573+ return
1574+ settle_cmd.extend(['--exit-if-exists=%s' % exists])
1575+ if timeout:
1576+ settle_cmd.extend(['--timeout=%s' % timeout])
1577+
1578+ return subp(settle_cmd)
1579+
1580+
1581 # vi: ts=4 expandtab
1582diff --git a/debian/changelog b/debian/changelog
1583index 45016a5..7199b4f 100644
1584--- a/debian/changelog
1585+++ b/debian/changelog
1586@@ -1,3 +1,28 @@
1587+cloud-init (18.2-27-g6ef92c98-0ubuntu1) bionic; urgency=medium
1588+
1589+ * New upstream snapshot.
1590+ - IBMCloud: recognize provisioning environment during debug boots.
1591+ (LP: #1767166)
1592+ - net: detect unstable network names and trigger a settle if needed
1593+ (LP: #1766287)
1594+ - IBMCloud: improve documentation in datasource.
1595+ - sysconfig: dhcp6 subnet type should not imply dhcpv4 [Vitaly Kuznetsov]
1596+ - packages/debian/control.in: add missing dependency on iproute2.
1597+ (LP: #1766711)
1598+ - DataSourceSmartOS: add locking of serial device.
1599+ [Mike Gerdts] (LP: #1746605)
1600+ - DataSourceSmartOS: sdc:hostname is ignored [Mike Gerdts] (LP: #1765085)
1601+ - DataSourceSmartOS: list() should always return a list
1602+ [Mike Gerdts] (LP: #1763480)
1603+ - schema: in validation, raise ImportError if strict but no jsonschema.
1604+ - set_passwords: Add newline to end of sshd config, only restart if
1605+ updated. (LP: #1677205)
1606+ - pylint: pay attention to unused variable warnings.
1607+ - doc: Add documentation for AliYun datasource. [Junjie Wang]
1608+ - Schema: do not warn on duplicate items in commands. (LP: #1764264)
1609+
1610+ -- Ryan Harper <ryan.harper@canonical.com> Thu, 26 Apr 2018 16:33:59 -0500
1611+
1612 cloud-init (18.2-14-g6d48d265-0ubuntu1) bionic; urgency=medium
1613
1614 * New upstream snapshot.
1615diff --git a/doc/rtd/topics/datasources.rst b/doc/rtd/topics/datasources.rst
1616index 7e2854d..38ba75d 100644
1617--- a/doc/rtd/topics/datasources.rst
1618+++ b/doc/rtd/topics/datasources.rst
1619@@ -80,6 +80,7 @@ Follow for more information.
1620 .. toctree::
1621 :maxdepth: 2
1622
1623+ datasources/aliyun.rst
1624 datasources/altcloud.rst
1625 datasources/azure.rst
1626 datasources/cloudsigma.rst
1627diff --git a/doc/rtd/topics/datasources/aliyun.rst b/doc/rtd/topics/datasources/aliyun.rst
1628new file mode 100644
1629index 0000000..3f4f40c
1630--- /dev/null
1631+++ b/doc/rtd/topics/datasources/aliyun.rst
1632@@ -0,0 +1,74 @@
1633+.. _datasource_aliyun:
1634+
1635+Alibaba Cloud (AliYun)
1636+======================
1637+The ``AliYun`` datasource reads data from Alibaba Cloud ECS. Support is
1638+present in cloud-init since 0.7.9.
1639+
1640+Metadata Service
1641+----------------
1642+The Alibaba Cloud metadata service is available at the well known url
1643+``http://100.100.100.200/``. For more information see
1644+Alibaba Cloud ECS on `metadata
1645+<https://www.alibabacloud.com/help/zh/faq-detail/49122.htm>`__.
1646+
1647+Versions
1648+^^^^^^^^
1649+Like the EC2 metadata service, Alibaba Cloud's metadata service provides
1650+versioned data under specific paths. As of April 2018, there are only
1651+``2016-01-01`` and ``latest`` versions.
1652+
1653+It is expected that the dated version will maintain a stable interface but
1654+``latest`` may change content at a future date.
1655+
1656+Cloud-init uses the ``2016-01-01`` version.
1657+
1658+You can list the versions available to your instance with:
1659+
1660+.. code-block:: shell-session
1661+
1662+ $ curl http://100.100.100.200/
1663+ 2016-01-01
1664+ latest
1665+
1666+Metadata
1667+^^^^^^^^
1668+Instance metadata can be queried at
1669+``http://100.100.100.200/2016-01-01/meta-data``
1670+
1671+.. code-block:: shell-session
1672+
1673+ $ curl http://100.100.100.200/2016-01-01/meta-data
1674+ dns-conf/
1675+ eipv4
1676+ hostname
1677+ image-id
1678+ instance-id
1679+ instance/
1680+ mac
1681+ network-type
1682+ network/
1683+ ntp-conf/
1684+ owner-account-id
1685+ private-ipv4
1686+ public-keys/
1687+ region-id
1688+ serial-number
1689+ source-address
1690+ sub-private-ipv4-list
1691+ vpc-cidr-block
1692+ vpc-id
1693+
1694+Userdata
1695+^^^^^^^^
1696+If provided, user-data will appear at
1697+``http://100.100.100.200/2016-01-01/user-data``.
1698+If no user-data is provided, this will return a 404.
1699+
1700+.. code-block:: shell-session
1701+
1702+ $ curl http://100.100.100.200/2016-01-01/user-data
1703+ #!/bin/sh
1704+ echo "Hello World."
1705+
1706+.. vi: textwidth=78
1707diff --git a/packages/debian/control.in b/packages/debian/control.in
1708index 46da6df..e9ed64f 100644
1709--- a/packages/debian/control.in
1710+++ b/packages/debian/control.in
1711@@ -11,6 +11,7 @@ Package: cloud-init
1712 Architecture: all
1713 Depends: ${misc:Depends},
1714 ${${python}:Depends},
1715+ iproute2,
1716 isc-dhcp-client
1717 Recommends: eatmydata, sudo, software-properties-common, gdisk
1718 XB-Python-Version: ${python:Versions}
1719diff --git a/tests/cloud_tests/bddeb.py b/tests/cloud_tests/bddeb.py
1720index b9cfcfa..f04d0cd 100644
1721--- a/tests/cloud_tests/bddeb.py
1722+++ b/tests/cloud_tests/bddeb.py
1723@@ -113,7 +113,7 @@ def bddeb(args):
1724 @return_value: fail count
1725 """
1726 LOG.info('preparing to build cloud-init deb')
1727- (res, failed) = run_stage('build deb', [partial(setup_build, args)])
1728+ _res, failed = run_stage('build deb', [partial(setup_build, args)])
1729 return failed
1730
1731 # vi: ts=4 expandtab
1732diff --git a/tests/cloud_tests/collect.py b/tests/cloud_tests/collect.py
1733index d4f9135..1ba7285 100644
1734--- a/tests/cloud_tests/collect.py
1735+++ b/tests/cloud_tests/collect.py
1736@@ -25,7 +25,8 @@ def collect_script(instance, base_dir, script, script_name):
1737 script.encode(), rcs=False,
1738 description='collect: {}'.format(script_name))
1739 if err:
1740- LOG.debug("collect script %s had stderr: %s", script_name, err)
1741+ LOG.debug("collect script %s exited '%s' and had stderr: %s",
1742+ script_name, err, exit)
1743 if not isinstance(out, bytes):
1744 raise util.PlatformError(
1745 "Collection of '%s' returned type %s, expected bytes: %s" %
1746diff --git a/tests/cloud_tests/platforms/instances.py b/tests/cloud_tests/platforms/instances.py
1747index 3bad021..cc439d2 100644
1748--- a/tests/cloud_tests/platforms/instances.py
1749+++ b/tests/cloud_tests/platforms/instances.py
1750@@ -108,7 +108,7 @@ class Instance(TargetBase):
1751 return client
1752 except (ConnectionRefusedError, AuthenticationException,
1753 BadHostKeyException, ConnectionResetError, SSHException,
1754- OSError) as e:
1755+ OSError):
1756 retries -= 1
1757 time.sleep(10)
1758
1759diff --git a/tests/cloud_tests/platforms/lxd/instance.py b/tests/cloud_tests/platforms/lxd/instance.py
1760index 0d957bc..1c17c78 100644
1761--- a/tests/cloud_tests/platforms/lxd/instance.py
1762+++ b/tests/cloud_tests/platforms/lxd/instance.py
1763@@ -152,9 +152,8 @@ class LXDInstance(Instance):
1764 return fp.read()
1765
1766 try:
1767- stdout, stderr = subp(
1768- ['lxc', 'console', '--show-log', self.name], decode=False)
1769- return stdout
1770+ return subp(['lxc', 'console', '--show-log', self.name],
1771+ decode=False)[0]
1772 except ProcessExecutionError as e:
1773 raise PlatformError(
1774 "console log",
1775@@ -214,11 +213,10 @@ def _has_proper_console_support():
1776 reason = "LXD Driver version not 3.x+ (%s)" % dver
1777 else:
1778 try:
1779- stdout, stderr = subp(['lxc', 'console', '--help'],
1780- decode=False)
1781+ stdout = subp(['lxc', 'console', '--help'], decode=False)[0]
1782 if not (b'console' in stdout and b'log' in stdout):
1783 reason = "no '--log' in lxc console --help"
1784- except ProcessExecutionError as e:
1785+ except ProcessExecutionError:
1786 reason = "no 'console' command in lxc client"
1787
1788 if reason:
1789diff --git a/tests/cloud_tests/setup_image.py b/tests/cloud_tests/setup_image.py
1790index 6d24211..4e19570 100644
1791--- a/tests/cloud_tests/setup_image.py
1792+++ b/tests/cloud_tests/setup_image.py
1793@@ -25,10 +25,9 @@ def installed_package_version(image, package, ensure_installed=True):
1794 else:
1795 raise NotImplementedError
1796
1797- msg = 'query version for package: {}'.format(package)
1798- (out, err, exit) = image.execute(
1799- cmd, description=msg, rcs=(0,) if ensure_installed else range(0, 256))
1800- return out.strip()
1801+ return image.execute(
1802+ cmd, description='query version for package: {}'.format(package),
1803+ rcs=(0,) if ensure_installed else range(0, 256))[0].strip()
1804
1805
1806 def install_deb(args, image):
1807@@ -54,7 +53,7 @@ def install_deb(args, image):
1808 remote_path], description=msg)
1809 # check installed deb version matches package
1810 fmt = ['-W', "--showformat=${Version}"]
1811- (out, err, exit) = image.execute(['dpkg-deb'] + fmt + [remote_path])
1812+ out = image.execute(['dpkg-deb'] + fmt + [remote_path])[0]
1813 expected_version = out.strip()
1814 found_version = installed_package_version(image, 'cloud-init')
1815 if expected_version != found_version:
1816@@ -85,7 +84,7 @@ def install_rpm(args, image):
1817 image.execute(['rpm', '-U', remote_path], description=msg)
1818
1819 fmt = ['--queryformat', '"%{VERSION}"']
1820- (out, err, exit) = image.execute(['rpm', '-q'] + fmt + [remote_path])
1821+ (out, _err, _exit) = image.execute(['rpm', '-q'] + fmt + [remote_path])
1822 expected_version = out.strip()
1823 found_version = installed_package_version(image, 'cloud-init')
1824 if expected_version != found_version:
1825diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
1826index 4fda8f9..0d1916b 100644
1827--- a/tests/cloud_tests/testcases/base.py
1828+++ b/tests/cloud_tests/testcases/base.py
1829@@ -159,7 +159,7 @@ class CloudTestCase(unittest.TestCase):
1830 expected_net_keys = [
1831 'public-ipv4s', 'ipv4-associations', 'local-hostname',
1832 'public-hostname']
1833- for mac, mac_data in macs.items():
1834+ for mac_data in macs.values():
1835 for key in expected_net_keys:
1836 self.assertIn(key, mac_data)
1837 self.assertIsNotNone(
1838diff --git a/tests/cloud_tests/testcases/examples/including_user_groups.py b/tests/cloud_tests/testcases/examples/including_user_groups.py
1839index 93b7a82..4067348 100644
1840--- a/tests/cloud_tests/testcases/examples/including_user_groups.py
1841+++ b/tests/cloud_tests/testcases/examples/including_user_groups.py
1842@@ -42,7 +42,7 @@ class TestUserGroups(base.CloudTestCase):
1843
1844 def test_user_root_in_secret(self):
1845 """Test root user is in 'secret' group."""
1846- user, _, groups = self.get_data_file('root_groups').partition(":")
1847+ _user, _, groups = self.get_data_file('root_groups').partition(":")
1848 self.assertIn("secret", groups.split(),
1849 msg="User root is not in group 'secret'")
1850
1851diff --git a/tests/cloud_tests/testcases/modules/user_groups.py b/tests/cloud_tests/testcases/modules/user_groups.py
1852index 93b7a82..4067348 100644
1853--- a/tests/cloud_tests/testcases/modules/user_groups.py
1854+++ b/tests/cloud_tests/testcases/modules/user_groups.py
1855@@ -42,7 +42,7 @@ class TestUserGroups(base.CloudTestCase):
1856
1857 def test_user_root_in_secret(self):
1858 """Test root user is in 'secret' group."""
1859- user, _, groups = self.get_data_file('root_groups').partition(":")
1860+ _user, _, groups = self.get_data_file('root_groups').partition(":")
1861 self.assertIn("secret", groups.split(),
1862 msg="User root is not in group 'secret'")
1863
1864diff --git a/tests/cloud_tests/util.py b/tests/cloud_tests/util.py
1865index 3dd4996..06f7d86 100644
1866--- a/tests/cloud_tests/util.py
1867+++ b/tests/cloud_tests/util.py
1868@@ -358,7 +358,7 @@ class TargetBase(object):
1869 # when sh is invoked with '-c', then the first argument is "$0"
1870 # which is commonly understood as the "program name".
1871 # 'read_data' is the program name, and 'remote_path' is '$1'
1872- stdout, stderr, rc = self._execute(
1873+ stdout, _stderr, rc = self._execute(
1874 ["sh", "-c", 'exec cat "$1"', 'read_data', remote_path])
1875 if rc != 0:
1876 raise RuntimeError("Failed to read file '%s'" % remote_path)
1877diff --git a/tests/unittests/test__init__.py b/tests/unittests/test__init__.py
1878index 25878d7..f1ab02e 100644
1879--- a/tests/unittests/test__init__.py
1880+++ b/tests/unittests/test__init__.py
1881@@ -214,7 +214,7 @@ class TestCmdlineUrl(CiTestCase):
1882 def test_no_key_found(self, m_read):
1883 cmdline = "ro mykey=http://example.com/foo root=foo"
1884 fpath = self.tmp_path("ccpath")
1885- lvl, msg = main.attempt_cmdline_url(
1886+ lvl, _msg = main.attempt_cmdline_url(
1887 fpath, network=True, cmdline=cmdline)
1888
1889 m_read.assert_not_called()
1890diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
1891index 3e8b791..88fe76c 100644
1892--- a/tests/unittests/test_datasource/test_azure.py
1893+++ b/tests/unittests/test_datasource/test_azure.py
1894@@ -214,7 +214,7 @@ scbus-1 on xpt0 bus 0
1895 self.assertIn(tag, x)
1896
1897 def tags_equal(x, y):
1898- for x_tag, x_val in x.items():
1899+ for x_val in x.values():
1900 y_val = y.get(x_val.tag)
1901 self.assertEqual(x_val.text, y_val.text)
1902
1903@@ -1216,7 +1216,7 @@ class TestAzureDataSourcePreprovisioning(CiTestCase):
1904 fake_resp.return_value = mock.MagicMock(status_code=200, text=content,
1905 content=content)
1906 dsa = dsaz.DataSourceAzure({}, distro=None, paths=self.paths)
1907- md, ud, cfg, d = dsa._reprovision()
1908+ md, _ud, cfg, _d = dsa._reprovision()
1909 self.assertEqual(md['local-hostname'], hostname)
1910 self.assertEqual(cfg['system_info']['default_user']['name'], username)
1911 self.assertEqual(fake_resp.call_args_list,
1912diff --git a/tests/unittests/test_datasource/test_ibmcloud.py b/tests/unittests/test_datasource/test_ibmcloud.py
1913index 621cfe4..e639ae4 100644
1914--- a/tests/unittests/test_datasource/test_ibmcloud.py
1915+++ b/tests/unittests/test_datasource/test_ibmcloud.py
1916@@ -259,4 +259,54 @@ class TestReadMD(test_helpers.CiTestCase):
1917 ret['metadata'])
1918
1919
1920+class TestIsIBMProvisioning(test_helpers.FilesystemMockingTestCase):
1921+ """Test the _is_ibm_provisioning method."""
1922+ inst_log = "/root/swinstall.log"
1923+ prov_cfg = "/root/provisioningConfiguration.cfg"
1924+ boot_ref = "/proc/1/environ"
1925+ with_logs = True
1926+
1927+ def _call_with_root(self, rootd):
1928+ self.reRoot(rootd)
1929+ return ibm._is_ibm_provisioning()
1930+
1931+ def test_no_config(self):
1932+ """No provisioning config means not provisioning."""
1933+ self.assertFalse(self._call_with_root(self.tmp_dir()))
1934+
1935+ def test_config_only(self):
1936+ """A provisioning config without a log means provisioning."""
1937+ rootd = self.tmp_dir()
1938+ test_helpers.populate_dir(rootd, {self.prov_cfg: "key=value"})
1939+ self.assertTrue(self._call_with_root(rootd))
1940+
1941+ def test_config_with_old_log(self):
1942+ """A config with a log from previous boot is not provisioning."""
1943+ rootd = self.tmp_dir()
1944+ data = {self.prov_cfg: ("key=value\nkey2=val2\n", -10),
1945+ self.inst_log: ("log data\n", -30),
1946+ self.boot_ref: ("PWD=/", 0)}
1947+ test_helpers.populate_dir_with_ts(rootd, data)
1948+ self.assertFalse(self._call_with_root(rootd=rootd))
1949+ self.assertIn("from previous boot", self.logs.getvalue())
1950+
1951+ def test_config_with_new_log(self):
1952+ """A config with a log from this boot is provisioning."""
1953+ rootd = self.tmp_dir()
1954+ data = {self.prov_cfg: ("key=value\nkey2=val2\n", -10),
1955+ self.inst_log: ("log data\n", 30),
1956+ self.boot_ref: ("PWD=/", 0)}
1957+ test_helpers.populate_dir_with_ts(rootd, data)
1958+ self.assertTrue(self._call_with_root(rootd=rootd))
1959+ self.assertIn("from current boot", self.logs.getvalue())
1960+
1961+ def test_config_and_log_no_reference(self):
1962+ """If the config and log existed, but no reference, assume not."""
1963+ rootd = self.tmp_dir()
1964+ test_helpers.populate_dir(
1965+ rootd, {self.prov_cfg: "key=value", self.inst_log: "log data\n"})
1966+ self.assertFalse(self._call_with_root(rootd=rootd))
1967+ self.assertIn("no reference file", self.logs.getvalue())
1968+
1969+
1970 # vi: ts=4 expandtab
1971diff --git a/tests/unittests/test_datasource/test_maas.py b/tests/unittests/test_datasource/test_maas.py
1972index 6e4031c..c84d067 100644
1973--- a/tests/unittests/test_datasource/test_maas.py
1974+++ b/tests/unittests/test_datasource/test_maas.py
1975@@ -53,7 +53,7 @@ class TestMAASDataSource(CiTestCase):
1976 my_d = os.path.join(self.tmp, "valid_extra")
1977 populate_dir(my_d, data)
1978
1979- ud, md, vd = DataSourceMAAS.read_maas_seed_dir(my_d)
1980+ ud, md, _vd = DataSourceMAAS.read_maas_seed_dir(my_d)
1981
1982 self.assertEqual(userdata, ud)
1983 for key in ('instance-id', 'local-hostname'):
1984@@ -149,7 +149,7 @@ class TestMAASDataSource(CiTestCase):
1985 'meta-data/local-hostname': 'test-hostname',
1986 'meta-data/vendor-data': yaml.safe_dump(expected_vd).encode(),
1987 }
1988- ud, md, vd = self.mock_read_maas_seed_url(
1989+ _ud, md, vd = self.mock_read_maas_seed_url(
1990 valid, "http://example.com/foo")
1991
1992 self.assertEqual(valid['meta-data/instance-id'], md['instance-id'])
1993diff --git a/tests/unittests/test_datasource/test_nocloud.py b/tests/unittests/test_datasource/test_nocloud.py
1994index 70d50de..cdbd1e1 100644
1995--- a/tests/unittests/test_datasource/test_nocloud.py
1996+++ b/tests/unittests/test_datasource/test_nocloud.py
1997@@ -51,9 +51,6 @@ class TestNoCloudDataSource(CiTestCase):
1998 class PsuedoException(Exception):
1999 pass
2000
2001- def my_find_devs_with(*args, **kwargs):
2002- raise PsuedoException
2003-
2004 self.mocks.enter_context(
2005 mock.patch.object(util, 'find_devs_with',
2006 side_effect=PsuedoException))
2007diff --git a/tests/unittests/test_datasource/test_smartos.py b/tests/unittests/test_datasource/test_smartos.py
2008index 2bea7a1..706e8eb 100644
2009--- a/tests/unittests/test_datasource/test_smartos.py
2010+++ b/tests/unittests/test_datasource/test_smartos.py
2011@@ -16,23 +16,27 @@ from __future__ import print_function
2012
2013 from binascii import crc32
2014 import json
2015+import multiprocessing
2016 import os
2017 import os.path
2018 import re
2019 import shutil
2020+import signal
2021 import stat
2022 import tempfile
2023+import unittest2
2024 import uuid
2025
2026 from cloudinit import serial
2027 from cloudinit.sources import DataSourceSmartOS
2028 from cloudinit.sources.DataSourceSmartOS import (
2029- convert_smartos_network_data as convert_net)
2030+ convert_smartos_network_data as convert_net,
2031+ SMARTOS_ENV_KVM, SERIAL_DEVICE, get_smartos_environ)
2032
2033 import six
2034
2035 from cloudinit import helpers as c_helpers
2036-from cloudinit.util import b64e
2037+from cloudinit.util import (b64e, subp)
2038
2039 from cloudinit.tests.helpers import mock, FilesystemMockingTestCase, TestCase
2040
2041@@ -319,6 +323,12 @@ MOCK_RETURNS = {
2042
2043 DMI_DATA_RETURN = 'smartdc'
2044
2045+# Useful for calculating the length of a frame body. A SUCCESS body will be
2046+# followed by more characters or be one character less if SUCCESS with no
2047+# payload. See Section 4.3 of https://eng.joyent.com/mdata/protocol.html.
2048+SUCCESS_LEN = len('0123abcd SUCCESS ')
2049+NOTFOUND_LEN = len('0123abcd NOTFOUND')
2050+
2051
2052 class PsuedoJoyentClient(object):
2053 def __init__(self, data=None):
2054@@ -431,6 +441,34 @@ class TestSmartOSDataSource(FilesystemMockingTestCase):
2055 self.assertEqual(MOCK_RETURNS['hostname'],
2056 dsrc.metadata['local-hostname'])
2057
2058+ def test_hostname_if_no_sdc_hostname(self):
2059+ my_returns = MOCK_RETURNS.copy()
2060+ my_returns['sdc:hostname'] = 'sdc-' + my_returns['hostname']
2061+ dsrc = self._get_ds(mockdata=my_returns)
2062+ ret = dsrc.get_data()
2063+ self.assertTrue(ret)
2064+ self.assertEqual(my_returns['hostname'],
2065+ dsrc.metadata['local-hostname'])
2066+
2067+ def test_sdc_hostname_if_no_hostname(self):
2068+ my_returns = MOCK_RETURNS.copy()
2069+ my_returns['sdc:hostname'] = 'sdc-' + my_returns['hostname']
2070+ del my_returns['hostname']
2071+ dsrc = self._get_ds(mockdata=my_returns)
2072+ ret = dsrc.get_data()
2073+ self.assertTrue(ret)
2074+ self.assertEqual(my_returns['sdc:hostname'],
2075+ dsrc.metadata['local-hostname'])
2076+
2077+ def test_sdc_uuid_if_no_hostname_or_sdc_hostname(self):
2078+ my_returns = MOCK_RETURNS.copy()
2079+ del my_returns['hostname']
2080+ dsrc = self._get_ds(mockdata=my_returns)
2081+ ret = dsrc.get_data()
2082+ self.assertTrue(ret)
2083+ self.assertEqual(my_returns['sdc:uuid'],
2084+ dsrc.metadata['local-hostname'])
2085+
2086 def test_userdata(self):
2087 dsrc = self._get_ds(mockdata=MOCK_RETURNS)
2088 ret = dsrc.get_data()
2089@@ -651,7 +689,7 @@ class TestJoyentMetadataClient(FilesystemMockingTestCase):
2090 self.response_parts = {
2091 'command': 'SUCCESS',
2092 'crc': 'b5a9ff00',
2093- 'length': 17 + len(b64e(self.metadata_value)),
2094+ 'length': SUCCESS_LEN + len(b64e(self.metadata_value)),
2095 'payload': b64e(self.metadata_value),
2096 'request_id': '{0:08x}'.format(self.request_id),
2097 }
2098@@ -787,7 +825,7 @@ class TestJoyentMetadataClient(FilesystemMockingTestCase):
2099 def test_get_metadata_returns_None_if_value_not_found(self):
2100 self.response_parts['payload'] = ''
2101 self.response_parts['command'] = 'NOTFOUND'
2102- self.response_parts['length'] = 17
2103+ self.response_parts['length'] = NOTFOUND_LEN
2104 client = self._get_client()
2105 client._checksum = lambda _: self.response_parts['crc']
2106 self.assertIsNone(client.get('some_key'))
2107@@ -838,6 +876,22 @@ class TestJoyentMetadataClient(FilesystemMockingTestCase):
2108 client.open_transport()
2109 self.assertTrue(reader.emptied)
2110
2111+ def test_list_metadata_returns_list(self):
2112+ parts = ['foo', 'bar']
2113+ value = b64e('\n'.join(parts))
2114+ self.response_parts['payload'] = value
2115+ self.response_parts['crc'] = '40873553'
2116+ self.response_parts['length'] = SUCCESS_LEN + len(value)
2117+ client = self._get_client()
2118+ self.assertEqual(client.list(), parts)
2119+
2120+ def test_list_metadata_returns_empty_list_if_no_customer_metadata(self):
2121+ del self.response_parts['payload']
2122+ self.response_parts['length'] = SUCCESS_LEN - 1
2123+ self.response_parts['crc'] = '14e563ba'
2124+ client = self._get_client()
2125+ self.assertEqual(client.list(), [])
2126+
2127
2128 class TestNetworkConversion(TestCase):
2129 def test_convert_simple(self):
2130@@ -973,4 +1027,63 @@ class TestNetworkConversion(TestCase):
2131 found = convert_net(SDC_NICS_SINGLE_GATEWAY)
2132 self.assertEqual(expected, found)
2133
2134+
2135+@unittest2.skipUnless(get_smartos_environ() == SMARTOS_ENV_KVM,
2136+ "Only supported on KVM and bhyve guests under SmartOS")
2137+@unittest2.skipUnless(os.access(SERIAL_DEVICE, os.W_OK),
2138+ "Requires write access to " + SERIAL_DEVICE)
2139+class TestSerialConcurrency(TestCase):
2140+ """
2141+ This class tests locking on an actual serial port, and as such can only
2142+ be run in a kvm or bhyve guest running on a SmartOS host. A test run on
2143+ a metadata socket will not be valid because a metadata socket ensures
2144+ there is only one session over a connection. In contrast, in the
2145+ absence of proper locking multiple processes opening the same serial
2146+ port can corrupt each others' exchanges with the metadata server.
2147+ """
2148+ def setUp(self):
2149+ self.mdata_proc = multiprocessing.Process(target=self.start_mdata_loop)
2150+ self.mdata_proc.start()
2151+ super(TestSerialConcurrency, self).setUp()
2152+
2153+ def tearDown(self):
2154+ # os.kill() rather than mdata_proc.terminate() to avoid console spam.
2155+ os.kill(self.mdata_proc.pid, signal.SIGKILL)
2156+ self.mdata_proc.join()
2157+ super(TestSerialConcurrency, self).tearDown()
2158+
2159+ def start_mdata_loop(self):
2160+ """
2161+ The mdata-get command is repeatedly run in a separate process so
2162+ that it may try to race with metadata operations performed in the
2163+ main test process. Use of mdata-get is better than two processes
2164+ using the protocol implementation in DataSourceSmartOS because we
2165+ are testing to be sure that cloud-init and mdata-get respect each
2166+ others locks.
2167+ """
2168+ rcs = list(range(0, 256))
2169+ while True:
2170+ subp(['mdata-get', 'sdc:routes'], rcs=rcs)
2171+
2172+ def test_all_keys(self):
2173+ self.assertIsNotNone(self.mdata_proc.pid)
2174+ ds = DataSourceSmartOS
2175+ keys = [tup[0] for tup in ds.SMARTOS_ATTRIB_MAP.values()]
2176+ keys.extend(ds.SMARTOS_ATTRIB_JSON.values())
2177+
2178+ client = ds.jmc_client_factory()
2179+ self.assertIsNotNone(client)
2180+
2181+ # The behavior that we are testing for was observed mdata-get running
2182+ # 10 times at roughly the same time as cloud-init fetched each key
2183+ # once. cloud-init would regularly see failures before making it
2184+ # through all keys once.
2185+ for _ in range(0, 3):
2186+ for key in keys:
2187+ # We don't care about the return value, just that it doesn't
2188+ # thrown any exceptions.
2189+ client.get(key)
2190+
2191+ self.assertIsNone(self.mdata_proc.exitcode)
2192+
2193 # vi: ts=4 expandtab
2194diff --git a/tests/unittests/test_ds_identify.py b/tests/unittests/test_ds_identify.py
2195index 5364398..ad7fe41 100644
2196--- a/tests/unittests/test_ds_identify.py
2197+++ b/tests/unittests/test_ds_identify.py
2198@@ -1,5 +1,6 @@
2199 # This file is part of cloud-init. See LICENSE file for license information.
2200
2201+from collections import namedtuple
2202 import copy
2203 import os
2204 from uuid import uuid4
2205@@ -7,7 +8,7 @@ from uuid import uuid4
2206 from cloudinit import safeyaml
2207 from cloudinit import util
2208 from cloudinit.tests.helpers import (
2209- CiTestCase, dir2dict, populate_dir)
2210+ CiTestCase, dir2dict, populate_dir, populate_dir_with_ts)
2211
2212 from cloudinit.sources import DataSourceIBMCloud as dsibm
2213
2214@@ -66,7 +67,6 @@ P_SYS_VENDOR = "sys/class/dmi/id/sys_vendor"
2215 P_SEED_DIR = "var/lib/cloud/seed"
2216 P_DSID_CFG = "etc/cloud/ds-identify.cfg"
2217
2218-IBM_PROVISIONING_CHECK_PATH = "/root/provisioningConfiguration.cfg"
2219 IBM_CONFIG_UUID = "9796-932E"
2220
2221 MOCK_VIRT_IS_KVM = {'name': 'detect_virt', 'RET': 'kvm', 'ret': 0}
2222@@ -74,11 +74,17 @@ MOCK_VIRT_IS_VMWARE = {'name': 'detect_virt', 'RET': 'vmware', 'ret': 0}
2223 MOCK_VIRT_IS_XEN = {'name': 'detect_virt', 'RET': 'xen', 'ret': 0}
2224 MOCK_UNAME_IS_PPC64 = {'name': 'uname', 'out': UNAME_PPC64EL, 'ret': 0}
2225
2226+shell_true = 0
2227+shell_false = 1
2228
2229-class TestDsIdentify(CiTestCase):
2230+CallReturn = namedtuple('CallReturn',
2231+ ['rc', 'stdout', 'stderr', 'cfg', 'files'])
2232+
2233+
2234+class DsIdentifyBase(CiTestCase):
2235 dsid_path = os.path.realpath('tools/ds-identify')
2236
2237- def call(self, rootd=None, mocks=None, args=None, files=None,
2238+ def call(self, rootd=None, mocks=None, func="main", args=None, files=None,
2239 policy_dmi=DI_DEFAULT_POLICY,
2240 policy_no_dmi=DI_DEFAULT_POLICY_NO_DMI,
2241 ec2_strict_id=DI_EC2_STRICT_ID_DEFAULT):
2242@@ -135,7 +141,7 @@ class TestDsIdentify(CiTestCase):
2243 mocklines.append(write_mock(d))
2244
2245 endlines = [
2246- 'main %s' % ' '.join(['"%s"' % s for s in args])
2247+ func + ' ' + ' '.join(['"%s"' % s for s in args])
2248 ]
2249
2250 with open(wrap, "w") as fp:
2251@@ -159,7 +165,7 @@ class TestDsIdentify(CiTestCase):
2252 cfg = {"_INVALID_YAML": contents,
2253 "_EXCEPTION": str(e)}
2254
2255- return rc, out, err, cfg, dir2dict(rootd)
2256+ return CallReturn(rc, out, err, cfg, dir2dict(rootd))
2257
2258 def _call_via_dict(self, data, rootd=None, **kwargs):
2259 # return output of self.call with a dict input like VALID_CFG[item]
2260@@ -190,6 +196,8 @@ class TestDsIdentify(CiTestCase):
2261 _print_run_output(rc, out, err, cfg, files)
2262 return rc, out, err, cfg, files
2263
2264+
2265+class TestDsIdentify(DsIdentifyBase):
2266 def test_wb_print_variables(self):
2267 """_print_info reports an array of discovered variables to stderr."""
2268 data = VALID_CFG['Azure-dmi-detection']
2269@@ -250,7 +258,10 @@ class TestDsIdentify(CiTestCase):
2270 Template provisioning with user-data has METADATA disk,
2271 datasource should return not found."""
2272 data = copy.deepcopy(VALID_CFG['IBMCloud-metadata'])
2273- data['files'] = {IBM_PROVISIONING_CHECK_PATH: 'xxx'}
2274+ # change the 'is_ibm_provisioning' mock to return 1 (false)
2275+ isprov_m = [m for m in data['mocks']
2276+ if m["name"] == "is_ibm_provisioning"][0]
2277+ isprov_m['ret'] = shell_true
2278 return self._check_via_dict(data, RC_NOT_FOUND)
2279
2280 def test_ibmcloud_template_userdata(self):
2281@@ -265,7 +276,8 @@ class TestDsIdentify(CiTestCase):
2282
2283 no disks attached. Datasource should return not found."""
2284 data = copy.deepcopy(VALID_CFG['IBMCloud-nodisks'])
2285- data['files'] = {IBM_PROVISIONING_CHECK_PATH: 'xxx'}
2286+ data['mocks'].append(
2287+ {'name': 'is_ibm_provisioning', 'ret': shell_true})
2288 return self._check_via_dict(data, RC_NOT_FOUND)
2289
2290 def test_ibmcloud_template_no_userdata(self):
2291@@ -446,6 +458,47 @@ class TestDsIdentify(CiTestCase):
2292 self._test_ds_found('Hetzner')
2293
2294
2295+class TestIsIBMProvisioning(DsIdentifyBase):
2296+ """Test the is_ibm_provisioning method in ds-identify."""
2297+
2298+ inst_log = "/root/swinstall.log"
2299+ prov_cfg = "/root/provisioningConfiguration.cfg"
2300+ boot_ref = "/proc/1/environ"
2301+ funcname = "is_ibm_provisioning"
2302+
2303+ def test_no_config(self):
2304+ """No provisioning config means not provisioning."""
2305+ ret = self.call(files={}, func=self.funcname)
2306+ self.assertEqual(shell_false, ret.rc)
2307+
2308+ def test_config_only(self):
2309+ """A provisioning config without a log means provisioning."""
2310+ ret = self.call(files={self.prov_cfg: "key=value"}, func=self.funcname)
2311+ self.assertEqual(shell_true, ret.rc)
2312+
2313+ def test_config_with_old_log(self):
2314+ """A config with a log from previous boot is not provisioning."""
2315+ rootd = self.tmp_dir()
2316+ data = {self.prov_cfg: ("key=value\nkey2=val2\n", -10),
2317+ self.inst_log: ("log data\n", -30),
2318+ self.boot_ref: ("PWD=/", 0)}
2319+ populate_dir_with_ts(rootd, data)
2320+ ret = self.call(rootd=rootd, func=self.funcname)
2321+ self.assertEqual(shell_false, ret.rc)
2322+ self.assertIn("from previous boot", ret.stderr)
2323+
2324+ def test_config_with_new_log(self):
2325+ """A config with a log from this boot is provisioning."""
2326+ rootd = self.tmp_dir()
2327+ data = {self.prov_cfg: ("key=value\nkey2=val2\n", -10),
2328+ self.inst_log: ("log data\n", 30),
2329+ self.boot_ref: ("PWD=/", 0)}
2330+ populate_dir_with_ts(rootd, data)
2331+ ret = self.call(rootd=rootd, func=self.funcname)
2332+ self.assertEqual(shell_true, ret.rc)
2333+ self.assertIn("from current boot", ret.stderr)
2334+
2335+
2336 def blkid_out(disks=None):
2337 """Convert a list of disk dictionaries into blkid content."""
2338 if disks is None:
2339@@ -639,6 +692,7 @@ VALID_CFG = {
2340 'ds': 'IBMCloud',
2341 'mocks': [
2342 MOCK_VIRT_IS_XEN,
2343+ {'name': 'is_ibm_provisioning', 'ret': shell_false},
2344 {'name': 'blkid', 'ret': 0,
2345 'out': blkid_out(
2346 [{'DEVNAME': 'xvda1', 'TYPE': 'vfat', 'PARTUUID': uuid4()},
2347@@ -652,6 +706,7 @@ VALID_CFG = {
2348 'ds': 'IBMCloud',
2349 'mocks': [
2350 MOCK_VIRT_IS_XEN,
2351+ {'name': 'is_ibm_provisioning', 'ret': shell_false},
2352 {'name': 'blkid', 'ret': 0,
2353 'out': blkid_out(
2354 [{'DEVNAME': 'xvda1', 'TYPE': 'ext3', 'PARTUUID': uuid4(),
2355@@ -669,6 +724,7 @@ VALID_CFG = {
2356 'ds': 'IBMCloud',
2357 'mocks': [
2358 MOCK_VIRT_IS_XEN,
2359+ {'name': 'is_ibm_provisioning', 'ret': shell_false},
2360 {'name': 'blkid', 'ret': 0,
2361 'out': blkid_out(
2362 [{'DEVNAME': 'xvda1', 'TYPE': 'vfat', 'PARTUUID': uuid4()},
2363diff --git a/tests/unittests/test_handler/test_handler_apt_source_v3.py b/tests/unittests/test_handler/test_handler_apt_source_v3.py
2364index 7bb1b7c..e486862 100644
2365--- a/tests/unittests/test_handler/test_handler_apt_source_v3.py
2366+++ b/tests/unittests/test_handler/test_handler_apt_source_v3.py
2367@@ -528,7 +528,7 @@ class TestAptSourceConfig(t_help.FilesystemMockingTestCase):
2368
2369 expected = sorted([npre + suff for opre, npre, suff in files])
2370 # create files
2371- for (opre, npre, suff) in files:
2372+ for (opre, _npre, suff) in files:
2373 fpath = os.path.join(apt_lists_d, opre + suff)
2374 util.write_file(fpath, content=fpath)
2375
2376diff --git a/tests/unittests/test_handler/test_handler_bootcmd.py b/tests/unittests/test_handler/test_handler_bootcmd.py
2377index 29fc25e..b137526 100644
2378--- a/tests/unittests/test_handler/test_handler_bootcmd.py
2379+++ b/tests/unittests/test_handler/test_handler_bootcmd.py
2380@@ -1,9 +1,10 @@
2381 # This file is part of cloud-init. See LICENSE file for license information.
2382
2383-from cloudinit.config import cc_bootcmd
2384+from cloudinit.config.cc_bootcmd import handle, schema
2385 from cloudinit.sources import DataSourceNone
2386 from cloudinit import (distros, helpers, cloud, util)
2387-from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
2388+from cloudinit.tests.helpers import (
2389+ CiTestCase, mock, SchemaTestCaseMixin, skipUnlessJsonSchema)
2390
2391 import logging
2392 import tempfile
2393@@ -50,7 +51,7 @@ class TestBootcmd(CiTestCase):
2394 """When the provided config doesn't contain bootcmd, skip it."""
2395 cfg = {}
2396 mycloud = self._get_cloud('ubuntu')
2397- cc_bootcmd.handle('notimportant', cfg, mycloud, LOG, None)
2398+ handle('notimportant', cfg, mycloud, LOG, None)
2399 self.assertIn(
2400 "Skipping module named notimportant, no 'bootcmd' key",
2401 self.logs.getvalue())
2402@@ -60,7 +61,7 @@ class TestBootcmd(CiTestCase):
2403 invalid_config = {'bootcmd': 1}
2404 cc = self._get_cloud('ubuntu')
2405 with self.assertRaises(TypeError) as context_manager:
2406- cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
2407+ handle('cc_bootcmd', invalid_config, cc, LOG, [])
2408 self.assertIn('Failed to shellify bootcmd', self.logs.getvalue())
2409 self.assertEqual(
2410 "Input to shellify was type 'int'. Expected list or tuple.",
2411@@ -76,7 +77,7 @@ class TestBootcmd(CiTestCase):
2412 invalid_config = {'bootcmd': 1}
2413 cc = self._get_cloud('ubuntu')
2414 with self.assertRaises(TypeError):
2415- cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
2416+ handle('cc_bootcmd', invalid_config, cc, LOG, [])
2417 self.assertIn(
2418 'Invalid config:\nbootcmd: 1 is not of type \'array\'',
2419 self.logs.getvalue())
2420@@ -93,7 +94,7 @@ class TestBootcmd(CiTestCase):
2421 'bootcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
2422 cc = self._get_cloud('ubuntu')
2423 with self.assertRaises(TypeError) as context_manager:
2424- cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
2425+ handle('cc_bootcmd', invalid_config, cc, LOG, [])
2426 expected_warnings = [
2427 'bootcmd.1: 20 is not valid under any of the given schemas',
2428 'bootcmd.3: {\'a\': \'n\'} is not valid under any of the given'
2429@@ -117,7 +118,7 @@ class TestBootcmd(CiTestCase):
2430 'echo {0} $INSTANCE_ID > {1}'.format(my_id, out_file)]}
2431
2432 with mock.patch(self._etmpfile_path, FakeExtendedTempFile):
2433- cc_bootcmd.handle('cc_bootcmd', valid_config, cc, LOG, [])
2434+ handle('cc_bootcmd', valid_config, cc, LOG, [])
2435 self.assertEqual(my_id + ' iid-datasource-none\n',
2436 util.load_file(out_file))
2437
2438@@ -128,7 +129,7 @@ class TestBootcmd(CiTestCase):
2439
2440 with mock.patch(self._etmpfile_path, FakeExtendedTempFile):
2441 with self.assertRaises(util.ProcessExecutionError) as ctxt_manager:
2442- cc_bootcmd.handle('does-not-matter', valid_config, cc, LOG, [])
2443+ handle('does-not-matter', valid_config, cc, LOG, [])
2444 self.assertIn(
2445 'Unexpected error while running command.\n'
2446 "Command: ['/bin/sh',",
2447@@ -138,4 +139,21 @@ class TestBootcmd(CiTestCase):
2448 self.logs.getvalue())
2449
2450
2451+@skipUnlessJsonSchema()
2452+class TestSchema(CiTestCase, SchemaTestCaseMixin):
2453+ """Directly test schema rather than through handle."""
2454+
2455+ schema = schema
2456+
2457+ def test_duplicates_are_fine_array_array(self):
2458+ """Duplicated commands array/array entries are allowed."""
2459+ self.assertSchemaValid(
2460+ ["byebye", "byebye"], 'command entries can be duplicate')
2461+
2462+ def test_duplicates_are_fine_array_string(self):
2463+ """Duplicated commands array/string entries are allowed."""
2464+ self.assertSchemaValid(
2465+ ["echo bye", "echo bye"], "command entries can be duplicate.")
2466+
2467+
2468 # vi: ts=4 expandtab
2469diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
2470index 02676aa..17c5355 100644
2471--- a/tests/unittests/test_handler/test_handler_ntp.py
2472+++ b/tests/unittests/test_handler/test_handler_ntp.py
2473@@ -76,7 +76,7 @@ class TestNtp(FilesystemMockingTestCase):
2474 template = TIMESYNCD_TEMPLATE
2475 else:
2476 template = NTP_TEMPLATE
2477- (confpath, template_fn) = self._generate_template(template=template)
2478+ (confpath, _template_fn) = self._generate_template(template=template)
2479 ntpconfig = copy.deepcopy(dcfg[client])
2480 ntpconfig['confpath'] = confpath
2481 ntpconfig['template_name'] = os.path.basename(confpath)
2482diff --git a/tests/unittests/test_handler/test_handler_runcmd.py b/tests/unittests/test_handler/test_handler_runcmd.py
2483index dbbb271..9ce334a 100644
2484--- a/tests/unittests/test_handler/test_handler_runcmd.py
2485+++ b/tests/unittests/test_handler/test_handler_runcmd.py
2486@@ -1,10 +1,11 @@
2487 # This file is part of cloud-init. See LICENSE file for license information.
2488
2489-from cloudinit.config import cc_runcmd
2490+from cloudinit.config.cc_runcmd import handle, schema
2491 from cloudinit.sources import DataSourceNone
2492 from cloudinit import (distros, helpers, cloud, util)
2493 from cloudinit.tests.helpers import (
2494- FilesystemMockingTestCase, skipUnlessJsonSchema)
2495+ CiTestCase, FilesystemMockingTestCase, SchemaTestCaseMixin,
2496+ skipUnlessJsonSchema)
2497
2498 import logging
2499 import os
2500@@ -35,7 +36,7 @@ class TestRuncmd(FilesystemMockingTestCase):
2501 """When the provided config doesn't contain runcmd, skip it."""
2502 cfg = {}
2503 mycloud = self._get_cloud('ubuntu')
2504- cc_runcmd.handle('notimportant', cfg, mycloud, LOG, None)
2505+ handle('notimportant', cfg, mycloud, LOG, None)
2506 self.assertIn(
2507 "Skipping module named notimportant, no 'runcmd' key",
2508 self.logs.getvalue())
2509@@ -44,7 +45,7 @@ class TestRuncmd(FilesystemMockingTestCase):
2510 """Commands which can't be converted to shell will raise errors."""
2511 invalid_config = {'runcmd': 1}
2512 cc = self._get_cloud('ubuntu')
2513- cc_runcmd.handle('cc_runcmd', invalid_config, cc, LOG, [])
2514+ handle('cc_runcmd', invalid_config, cc, LOG, [])
2515 self.assertIn(
2516 'Failed to shellify 1 into file'
2517 ' /var/lib/cloud/instances/iid-datasource-none/scripts/runcmd',
2518@@ -59,7 +60,7 @@ class TestRuncmd(FilesystemMockingTestCase):
2519 """
2520 invalid_config = {'runcmd': 1}
2521 cc = self._get_cloud('ubuntu')
2522- cc_runcmd.handle('cc_runcmd', invalid_config, cc, LOG, [])
2523+ handle('cc_runcmd', invalid_config, cc, LOG, [])
2524 self.assertIn(
2525 'Invalid config:\nruncmd: 1 is not of type \'array\'',
2526 self.logs.getvalue())
2527@@ -75,7 +76,7 @@ class TestRuncmd(FilesystemMockingTestCase):
2528 invalid_config = {
2529 'runcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
2530 cc = self._get_cloud('ubuntu')
2531- cc_runcmd.handle('cc_runcmd', invalid_config, cc, LOG, [])
2532+ handle('cc_runcmd', invalid_config, cc, LOG, [])
2533 expected_warnings = [
2534 'runcmd.1: 20 is not valid under any of the given schemas',
2535 'runcmd.3: {\'a\': \'n\'} is not valid under any of the given'
2536@@ -90,7 +91,7 @@ class TestRuncmd(FilesystemMockingTestCase):
2537 """Valid runcmd schema is written to a runcmd shell script."""
2538 valid_config = {'runcmd': [['ls', '/']]}
2539 cc = self._get_cloud('ubuntu')
2540- cc_runcmd.handle('cc_runcmd', valid_config, cc, LOG, [])
2541+ handle('cc_runcmd', valid_config, cc, LOG, [])
2542 runcmd_file = os.path.join(
2543 self.new_root,
2544 'var/lib/cloud/instances/iid-datasource-none/scripts/runcmd')
2545@@ -99,4 +100,22 @@ class TestRuncmd(FilesystemMockingTestCase):
2546 self.assertEqual(0o700, stat.S_IMODE(file_stat.st_mode))
2547
2548
2549+@skipUnlessJsonSchema()
2550+class TestSchema(CiTestCase, SchemaTestCaseMixin):
2551+ """Directly test schema rather than through handle."""
2552+
2553+ schema = schema
2554+
2555+ def test_duplicates_are_fine_array_array(self):
2556+ """Duplicated commands array/array entries are allowed."""
2557+ self.assertSchemaValid(
2558+ [["echo", "bye"], ["echo", "bye"]],
2559+ "command entries can be duplicate.")
2560+
2561+ def test_duplicates_are_fine_array_string(self):
2562+ """Duplicated commands array/string entries are allowed."""
2563+ self.assertSchemaValid(
2564+ ["echo bye", "echo bye"],
2565+ "command entries can be duplicate.")
2566+
2567 # vi: ts=4 expandtab
2568diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
2569index c12a487..fac8267 100644
2570--- a/tests/unittests/test_net.py
2571+++ b/tests/unittests/test_net.py
2572@@ -553,6 +553,43 @@ NETWORK_CONFIGS = {
2573 """),
2574 },
2575 },
2576+ 'dhcpv6_only': {
2577+ 'expected_eni': textwrap.dedent("""\
2578+ auto lo
2579+ iface lo inet loopback
2580+
2581+ auto iface0
2582+ iface iface0 inet6 dhcp
2583+ """).rstrip(' '),
2584+ 'expected_netplan': textwrap.dedent("""
2585+ network:
2586+ version: 2
2587+ ethernets:
2588+ iface0:
2589+ dhcp6: true
2590+ """).rstrip(' '),
2591+ 'yaml': textwrap.dedent("""\
2592+ version: 1
2593+ config:
2594+ - type: 'physical'
2595+ name: 'iface0'
2596+ subnets:
2597+ - {'type': 'dhcp6'}
2598+ """).rstrip(' '),
2599+ 'expected_sysconfig': {
2600+ 'ifcfg-iface0': textwrap.dedent("""\
2601+ BOOTPROTO=none
2602+ DEVICE=iface0
2603+ DHCPV6C=yes
2604+ IPV6INIT=yes
2605+ DEVICE=iface0
2606+ NM_CONTROLLED=no
2607+ ONBOOT=yes
2608+ TYPE=Ethernet
2609+ USERCTL=no
2610+ """),
2611+ },
2612+ },
2613 'all': {
2614 'expected_eni': ("""\
2615 auto lo
2616@@ -740,7 +777,7 @@ pre-down route del -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true
2617 """miimon=100"
2618 BONDING_SLAVE0=eth1
2619 BONDING_SLAVE1=eth2
2620- BOOTPROTO=dhcp
2621+ BOOTPROTO=none
2622 DEVICE=bond0
2623 DHCPV6C=yes
2624 IPV6INIT=yes
2625@@ -1405,6 +1442,7 @@ DEFAULT_DEV_ATTRS = {
2626 "address": "07-1C-C6-75-A4-BE",
2627 "device/driver": None,
2628 "device/device": None,
2629+ "name_assign_type": "4",
2630 }
2631 }
2632
2633@@ -1452,11 +1490,14 @@ class TestGenerateFallbackConfig(CiTestCase):
2634 'eth0': {
2635 'bridge': False, 'carrier': False, 'dormant': False,
2636 'operstate': 'down', 'address': '00:11:22:33:44:55',
2637- 'device/driver': 'hv_netsvc', 'device/device': '0x3'},
2638+ 'device/driver': 'hv_netsvc', 'device/device': '0x3',
2639+ 'name_assign_type': '4'},
2640 'eth1': {
2641 'bridge': False, 'carrier': False, 'dormant': False,
2642 'operstate': 'down', 'address': '00:11:22:33:44:55',
2643- 'device/driver': 'mlx4_core', 'device/device': '0x7'},
2644+ 'device/driver': 'mlx4_core', 'device/device': '0x7',
2645+ 'name_assign_type': '4'},
2646+
2647 }
2648
2649 tmp_dir = self.tmp_dir()
2650@@ -1512,11 +1553,13 @@ iface eth0 inet dhcp
2651 'eth1': {
2652 'bridge': False, 'carrier': False, 'dormant': False,
2653 'operstate': 'down', 'address': '00:11:22:33:44:55',
2654- 'device/driver': 'hv_netsvc', 'device/device': '0x3'},
2655+ 'device/driver': 'hv_netsvc', 'device/device': '0x3',
2656+ 'name_assign_type': '4'},
2657 'eth0': {
2658 'bridge': False, 'carrier': False, 'dormant': False,
2659 'operstate': 'down', 'address': '00:11:22:33:44:55',
2660- 'device/driver': 'mlx4_core', 'device/device': '0x7'},
2661+ 'device/driver': 'mlx4_core', 'device/device': '0x7',
2662+ 'name_assign_type': '4'},
2663 }
2664
2665 tmp_dir = self.tmp_dir()
2666@@ -1565,6 +1608,65 @@ iface eth1 inet dhcp
2667 ]
2668 self.assertEqual(", ".join(expected_rule) + '\n', contents.lstrip())
2669
2670+ @mock.patch("cloudinit.util.udevadm_settle")
2671+ @mock.patch("cloudinit.net.sys_dev_path")
2672+ @mock.patch("cloudinit.net.read_sys_net")
2673+ @mock.patch("cloudinit.net.get_devicelist")
2674+ def test_unstable_names(self, mock_get_devicelist, mock_read_sys_net,
2675+ mock_sys_dev_path, mock_settle):
2676+ """verify that udevadm settle is called when we find unstable names"""
2677+ devices = {
2678+ 'eth0': {
2679+ 'bridge': False, 'carrier': False, 'dormant': False,
2680+ 'operstate': 'down', 'address': '00:11:22:33:44:55',
2681+ 'device/driver': 'hv_netsvc', 'device/device': '0x3',
2682+ 'name_assign_type': False},
2683+ 'ens4': {
2684+ 'bridge': False, 'carrier': False, 'dormant': False,
2685+ 'operstate': 'down', 'address': '00:11:22:33:44:55',
2686+ 'device/driver': 'mlx4_core', 'device/device': '0x7',
2687+ 'name_assign_type': '4'},
2688+
2689+ }
2690+
2691+ tmp_dir = self.tmp_dir()
2692+ _setup_test(tmp_dir, mock_get_devicelist,
2693+ mock_read_sys_net, mock_sys_dev_path,
2694+ dev_attrs=devices)
2695+ net.generate_fallback_config(config_driver=True)
2696+ self.assertEqual(1, mock_settle.call_count)
2697+
2698+ @mock.patch("cloudinit.util.get_cmdline")
2699+ @mock.patch("cloudinit.util.udevadm_settle")
2700+ @mock.patch("cloudinit.net.sys_dev_path")
2701+ @mock.patch("cloudinit.net.read_sys_net")
2702+ @mock.patch("cloudinit.net.get_devicelist")
2703+ def test_unstable_names_disabled(self, mock_get_devicelist,
2704+ mock_read_sys_net, mock_sys_dev_path,
2705+ mock_settle, m_get_cmdline):
2706+ """verify udevadm settle not called when cmdline has net.ifnames=0"""
2707+ devices = {
2708+ 'eth0': {
2709+ 'bridge': False, 'carrier': False, 'dormant': False,
2710+ 'operstate': 'down', 'address': '00:11:22:33:44:55',
2711+ 'device/driver': 'hv_netsvc', 'device/device': '0x3',
2712+ 'name_assign_type': False},
2713+ 'ens4': {
2714+ 'bridge': False, 'carrier': False, 'dormant': False,
2715+ 'operstate': 'down', 'address': '00:11:22:33:44:55',
2716+ 'device/driver': 'mlx4_core', 'device/device': '0x7',
2717+ 'name_assign_type': '4'},
2718+
2719+ }
2720+
2721+ m_get_cmdline.return_value = 'net.ifnames=0'
2722+ tmp_dir = self.tmp_dir()
2723+ _setup_test(tmp_dir, mock_get_devicelist,
2724+ mock_read_sys_net, mock_sys_dev_path,
2725+ dev_attrs=devices)
2726+ net.generate_fallback_config(config_driver=True)
2727+ self.assertEqual(0, mock_settle.call_count)
2728+
2729
2730 class TestSysConfigRendering(CiTestCase):
2731
2732@@ -1829,6 +1931,12 @@ USERCTL=no
2733 self._compare_files_to_expected(entry['expected_sysconfig'], found)
2734 self._assert_headers(found)
2735
2736+ def test_dhcpv6_only_config(self):
2737+ entry = NETWORK_CONFIGS['dhcpv6_only']
2738+ found = self._render_and_read(network_config=yaml.load(entry['yaml']))
2739+ self._compare_files_to_expected(entry['expected_sysconfig'], found)
2740+ self._assert_headers(found)
2741+
2742
2743 class TestEniNetRendering(CiTestCase):
2744
2745@@ -2277,6 +2385,13 @@ class TestNetplanRoundTrip(CiTestCase):
2746 entry['expected_netplan'].splitlines(),
2747 files['/etc/netplan/50-cloud-init.yaml'].splitlines())
2748
2749+ def testsimple_render_dhcpv6_only(self):
2750+ entry = NETWORK_CONFIGS['dhcpv6_only']
2751+ files = self._render_and_read(network_config=yaml.load(entry['yaml']))
2752+ self.assertEqual(
2753+ entry['expected_netplan'].splitlines(),
2754+ files['/etc/netplan/50-cloud-init.yaml'].splitlines())
2755+
2756 def testsimple_render_all(self):
2757 entry = NETWORK_CONFIGS['all']
2758 files = self._render_and_read(network_config=yaml.load(entry['yaml']))
2759@@ -2345,6 +2460,13 @@ class TestEniRoundTrip(CiTestCase):
2760 entry['expected_eni'].splitlines(),
2761 files['/etc/network/interfaces'].splitlines())
2762
2763+ def testsimple_render_dhcpv6_only(self):
2764+ entry = NETWORK_CONFIGS['dhcpv6_only']
2765+ files = self._render_and_read(network_config=yaml.load(entry['yaml']))
2766+ self.assertEqual(
2767+ entry['expected_eni'].splitlines(),
2768+ files['/etc/network/interfaces'].splitlines())
2769+
2770 def testsimple_render_v4_and_v6_static(self):
2771 entry = NETWORK_CONFIGS['v4_and_v6_static']
2772 files = self._render_and_read(network_config=yaml.load(entry['yaml']))
2773diff --git a/tests/unittests/test_sshutil.py b/tests/unittests/test_sshutil.py
2774index 4c62c8b..73ae897 100644
2775--- a/tests/unittests/test_sshutil.py
2776+++ b/tests/unittests/test_sshutil.py
2777@@ -4,6 +4,7 @@ from mock import patch
2778
2779 from cloudinit import ssh_util
2780 from cloudinit.tests import helpers as test_helpers
2781+from cloudinit import util
2782
2783
2784 VALID_CONTENT = {
2785@@ -56,7 +57,7 @@ TEST_OPTIONS = (
2786 'user \"root\".\';echo;sleep 10"')
2787
2788
2789-class TestAuthKeyLineParser(test_helpers.TestCase):
2790+class TestAuthKeyLineParser(test_helpers.CiTestCase):
2791
2792 def test_simple_parse(self):
2793 # test key line with common 3 fields (keytype, base64, comment)
2794@@ -126,7 +127,7 @@ class TestAuthKeyLineParser(test_helpers.TestCase):
2795 self.assertFalse(key.valid())
2796
2797
2798-class TestUpdateAuthorizedKeys(test_helpers.TestCase):
2799+class TestUpdateAuthorizedKeys(test_helpers.CiTestCase):
2800
2801 def test_new_keys_replace(self):
2802 """new entries with the same base64 should replace old."""
2803@@ -168,7 +169,7 @@ class TestUpdateAuthorizedKeys(test_helpers.TestCase):
2804 self.assertEqual(expected, found)
2805
2806
2807-class TestParseSSHConfig(test_helpers.TestCase):
2808+class TestParseSSHConfig(test_helpers.CiTestCase):
2809
2810 def setUp(self):
2811 self.load_file_patch = patch('cloudinit.ssh_util.util.load_file')
2812@@ -235,4 +236,94 @@ class TestParseSSHConfig(test_helpers.TestCase):
2813 self.assertEqual('foo', ret[0].key)
2814 self.assertEqual('bar', ret[0].value)
2815
2816+
2817+class TestUpdateSshConfigLines(test_helpers.CiTestCase):
2818+ """Test the update_ssh_config_lines method."""
2819+ exlines = [
2820+ "#PasswordAuthentication yes",
2821+ "UsePAM yes",
2822+ "# Comment line",
2823+ "AcceptEnv LANG LC_*",
2824+ "X11Forwarding no",
2825+ ]
2826+ pwauth = "PasswordAuthentication"
2827+
2828+ def check_line(self, line, opt, val):
2829+ self.assertEqual(line.key, opt.lower())
2830+ self.assertEqual(line.value, val)
2831+ self.assertIn(opt, str(line))
2832+ self.assertIn(val, str(line))
2833+
2834+ def test_new_option_added(self):
2835+ """A single update of non-existing option."""
2836+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
2837+ result = ssh_util.update_ssh_config_lines(lines, {'MyKey': 'MyVal'})
2838+ self.assertEqual(['MyKey'], result)
2839+ self.check_line(lines[-1], "MyKey", "MyVal")
2840+
2841+ def test_commented_out_not_updated_but_appended(self):
2842+ """Implementation does not un-comment and update lines."""
2843+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
2844+ result = ssh_util.update_ssh_config_lines(lines, {self.pwauth: "no"})
2845+ self.assertEqual([self.pwauth], result)
2846+ self.check_line(lines[-1], self.pwauth, "no")
2847+
2848+ def test_single_option_updated(self):
2849+ """A single update should have change made and line updated."""
2850+ opt, val = ("UsePAM", "no")
2851+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
2852+ result = ssh_util.update_ssh_config_lines(lines, {opt: val})
2853+ self.assertEqual([opt], result)
2854+ self.check_line(lines[1], opt, val)
2855+
2856+ def test_multiple_updates_with_add(self):
2857+ """Verify multiple updates some added some changed, some not."""
2858+ updates = {"UsePAM": "no", "X11Forwarding": "no", "NewOpt": "newval",
2859+ "AcceptEnv": "LANG ADD LC_*"}
2860+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
2861+ result = ssh_util.update_ssh_config_lines(lines, updates)
2862+ self.assertEqual(set(["UsePAM", "NewOpt", "AcceptEnv"]), set(result))
2863+ self.check_line(lines[3], "AcceptEnv", updates["AcceptEnv"])
2864+
2865+ def test_return_empty_if_no_changes(self):
2866+ """If there are no changes, then return should be empty list."""
2867+ updates = {"UsePAM": "yes"}
2868+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
2869+ result = ssh_util.update_ssh_config_lines(lines, updates)
2870+ self.assertEqual([], result)
2871+ self.assertEqual(self.exlines, [str(l) for l in lines])
2872+
2873+ def test_keycase_not_modified(self):
2874+ """Original case of key should not be changed on update.
2875+ This behavior is to keep original config as much intact as can be."""
2876+ updates = {"usepam": "no"}
2877+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
2878+ result = ssh_util.update_ssh_config_lines(lines, updates)
2879+ self.assertEqual(["usepam"], result)
2880+ self.assertEqual("UsePAM no", str(lines[1]))
2881+
2882+
2883+class TestUpdateSshConfig(test_helpers.CiTestCase):
2884+ cfgdata = '\n'.join(["#Option val", "MyKey ORIG_VAL", ""])
2885+
2886+ def test_modified(self):
2887+ mycfg = self.tmp_path("ssh_config_1")
2888+ util.write_file(mycfg, self.cfgdata)
2889+ ret = ssh_util.update_ssh_config({"MyKey": "NEW_VAL"}, mycfg)
2890+ self.assertTrue(ret)
2891+ found = util.load_file(mycfg)
2892+ self.assertEqual(self.cfgdata.replace("ORIG_VAL", "NEW_VAL"), found)
2893+ # assert there is a newline at end of file (LP: #1677205)
2894+ self.assertEqual('\n', found[-1])
2895+
2896+ def test_not_modified(self):
2897+ mycfg = self.tmp_path("ssh_config_2")
2898+ util.write_file(mycfg, self.cfgdata)
2899+ with patch("cloudinit.ssh_util.util.write_file") as m_write_file:
2900+ ret = ssh_util.update_ssh_config({"MyKey": "ORIG_VAL"}, mycfg)
2901+ self.assertFalse(ret)
2902+ self.assertEqual(self.cfgdata, util.load_file(mycfg))
2903+ m_write_file.assert_not_called()
2904+
2905+
2906 # vi: ts=4 expandtab
2907diff --git a/tests/unittests/test_templating.py b/tests/unittests/test_templating.py
2908index 1080e13..20c87ef 100644
2909--- a/tests/unittests/test_templating.py
2910+++ b/tests/unittests/test_templating.py
2911@@ -50,12 +50,12 @@ class TestTemplates(test_helpers.CiTestCase):
2912 def test_detection(self):
2913 blob = "## template:cheetah"
2914
2915- (template_type, renderer, contents) = templater.detect_template(blob)
2916+ (template_type, _renderer, contents) = templater.detect_template(blob)
2917 self.assertIn("cheetah", template_type)
2918 self.assertEqual("", contents.strip())
2919
2920 blob = "blahblah $blah"
2921- (template_type, renderer, contents) = templater.detect_template(blob)
2922+ (template_type, _renderer, _contents) = templater.detect_template(blob)
2923 self.assertIn("cheetah", template_type)
2924 self.assertEqual(blob, contents)
2925
2926diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
2927index e04ea03..84941c7 100644
2928--- a/tests/unittests/test_util.py
2929+++ b/tests/unittests/test_util.py
2930@@ -774,11 +774,11 @@ class TestSubp(helpers.CiTestCase):
2931
2932 def test_subp_reads_env(self):
2933 with mock.patch.dict("os.environ", values={'FOO': 'BAR'}):
2934- out, err = util.subp(self.printenv + ['FOO'], capture=True)
2935+ out, _err = util.subp(self.printenv + ['FOO'], capture=True)
2936 self.assertEqual('FOO=BAR', out.splitlines()[0])
2937
2938 def test_subp_env_and_update_env(self):
2939- out, err = util.subp(
2940+ out, _err = util.subp(
2941 self.printenv + ['FOO', 'HOME', 'K1', 'K2'], capture=True,
2942 env={'FOO': 'BAR'},
2943 update_env={'HOME': '/myhome', 'K2': 'V2'})
2944@@ -788,7 +788,7 @@ class TestSubp(helpers.CiTestCase):
2945 def test_subp_update_env(self):
2946 extra = {'FOO': 'BAR', 'HOME': '/root', 'K1': 'V1'}
2947 with mock.patch.dict("os.environ", values=extra):
2948- out, err = util.subp(
2949+ out, _err = util.subp(
2950 self.printenv + ['FOO', 'HOME', 'K1', 'K2'], capture=True,
2951 update_env={'HOME': '/myhome', 'K2': 'V2'})
2952
2953diff --git a/tools/ds-identify b/tools/ds-identify
2954index 9a2db5c..7fff5d1 100755
2955--- a/tools/ds-identify
2956+++ b/tools/ds-identify
2957@@ -125,6 +125,7 @@ DI_ON_NOTFOUND=""
2958 DI_EC2_STRICT_ID_DEFAULT="true"
2959
2960 _IS_IBM_CLOUD=""
2961+_IS_IBM_PROVISIONING=""
2962
2963 error() {
2964 set -- "ERROR:" "$@";
2965@@ -1006,7 +1007,25 @@ dscheck_Hetzner() {
2966 }
2967
2968 is_ibm_provisioning() {
2969- [ -f "${PATH_ROOT}/root/provisioningConfiguration.cfg" ]
2970+ local pcfg="${PATH_ROOT}/root/provisioningConfiguration.cfg"
2971+ local logf="${PATH_ROOT}/root/swinstall.log"
2972+ local is_prov=false msg="config '$pcfg' did not exist."
2973+ if [ -f "$pcfg" ]; then
2974+ msg="config '$pcfg' exists."
2975+ is_prov=true
2976+ if [ -f "$logf" ]; then
2977+ if [ "$logf" -nt "$PATH_PROC_1_ENVIRON" ]; then
2978+ msg="$msg log '$logf' from current boot."
2979+ else
2980+ is_prov=false
2981+ msg="$msg log '$logf' from previous boot."
2982+ fi
2983+ else
2984+ msg="$msg log '$logf' did not exist."
2985+ fi
2986+ fi
2987+ debug 2 "ibm_provisioning=$is_prov: $msg"
2988+ [ "$is_prov" = "true" ]
2989 }
2990
2991 is_ibm_cloud() {

Subscribers

People subscribed via source and target branches