Merge ~smoser/cloud-init:fix/1677205-eol-on-sshd_config into cloud-init:master

Proposed by Scott Moser
Status: Merged
Approved by: Scott Moser
Approved revision: 770101c0b5a9e7455fbd97a04c7164b1b213eb5d
Merge reported by: Chad Smith
Merged at revision: 4952a8545b61ceb2fe26a933d2f64020d0281703
Proposed branch: ~smoser/cloud-init:fix/1677205-eol-on-sshd_config
Merge into: cloud-init:master
Diff against target: 440 lines (+273/-70)
4 files modified
cloudinit/config/cc_set_passwords.py (+45/-60)
cloudinit/config/tests/test_set_passwords.py (+71/-0)
cloudinit/ssh_util.py (+63/-7)
tests/unittests/test_sshutil.py (+94/-3)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Ryan Harper Approve
Review via email: mp+343246@code.launchpad.net

Commit message

set_passwords: Add newline to end of sshd config, only restart if updated.

This admittedly does a fairly extensive re-factor to simply add a newline
to the end of sshd_config.

It makes the ssh_config updating portion of set_passwords more testable
and adds tests for that.

The new function is in 'update_ssh_config_lines' which allows you
to update a config with multiple changes even though only a single one
is currently used.

We also only restart the ssh daemon now if a change was made to the
config file. Before it was always restarted if the user specified
a value for ssh_pwauth other than 'unchanged'.

Thanks to Lorens Kockum for initial diagnosis and patch.

LP: #1677205

Description of the change

see commit message

To post a comment you must log in.
Revision history for this message
Scott Moser (smoser) wrote :

Increases test coverage:
 * ssh_util from 60 -> 70%
 * cc_set_passwords: 6% -> 27%

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

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

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

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

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

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) :
Revision history for this message
Scott Moser (smoser) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

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

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

This is fine. One comment w.r.t the boolean construction. Take that or ignore.

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

Your use of timeit is a bit flawed. You really need to use 'setup'
in almost all cases. otherwise you're testing both the construction
of the list and the evaluation of your condition in each loop. Obviously
if the construction of the list takes 10x the test then your results
will not be meaningful.

So I tried..

>>> timeit.timeit('True if a else False', setup='a=list(range(0,100))', number=number)
0.048113107681274414
>>> timeit.timeit('len(a)', setup='a=list(range(0,100))', number=number)
0.054379940032958984
>>> timeit.timeit('len(a) == 0', setup='a=list(range(0,100))', number=number)
0.06807184219360352
>>> timeit.timeit('bool(a)', setup='a=list(range(0,100))', number=number)
0.11591291427612305

That is mind-boggling.
How can 'True if a else False' *not* call bool(a) in order to evaluate 'if a'?

Even crazier...
Here we define 'mybool' a regular old function and take the function overhead but still do the 'True if a else False'.
*That* is faster than bool(a) when 'a' is an empty list bug does get slower when 'a' is a larger list.

>>> timeit.timeit('mybool(a)', setup='a=[]\ndef mybool(b):\n return True if b else False', number=number)
0.09648299217224121
>>> timeit.timeit('bool(a)', setup='a=[]\ndef mybool(b):\n return True if b else False', number=number)
0.12272787094116211
>>> timeit.timeit('mybool(a)', setup='a=list(range(0,300))\ndef mybool(b):\n return True if b else False', number=number)
0.09898996353149414
>>> timeit.timeit('bool(a)', setup='a=list(range(0,300))\ndef mybool(b):\n return True if b else False', number=number)
0.11946702003479004

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

I think the most obvious implementation of "return True if the list has entries and False otherwise" is in order of most to least obvious:

return len(changed) == 0
return bool(changed)
return True if changed else False

The fact that bool(changed) is "so slow" is unfortunate.

I think I'll go with len(changed) == 0

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

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

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

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

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

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

> Your use of timeit is a bit flawed. You really need to use 'setup'
> in almost all cases. otherwise you're testing both the construction
> of the list and the evaluation of your condition in each loop. Obviously
> if the construction of the list takes 10x the test then your results
> will not be meaningful.

I'm well aware of construction costs, however, given the size of list it won't have
a significant effect on the outcome.

>
> So I tried..
>
> >>> timeit.timeit('True if a else False', setup='a=list(range(0,100))',
> number=number)
> 0.048113107681274414
> >>> timeit.timeit('len(a)', setup='a=list(range(0,100))', number=number)
> 0.054379940032958984
> >>> timeit.timeit('len(a) == 0', setup='a=list(range(0,100))', number=number)
> 0.06807184219360352
> >>> timeit.timeit('bool(a)', setup='a=list(range(0,100))', number=number)
> 0.11591291427612305

That matches what I found.

>
> That is mind-boggling.
> How can 'True if a else False' *not* call bool(a) in order to evaluate 'if a'?

https://docs.python.org/3/library/stdtypes.html#truth

>
> Even crazier...
> Here we define 'mybool' a regular old function and take the function overhead
> but still do the 'True if a else False'.
> *That* is faster than bool(a) when 'a' is an empty list bug does get slower
> when 'a' is a larger list.
>
> >>> timeit.timeit('mybool(a)', setup='a=[]\ndef mybool(b):\n return True if b
> else False', number=number)
> 0.09648299217224121
> >>> timeit.timeit('bool(a)', setup='a=[]\ndef mybool(b):\n return True if b
> else False', number=number)
> 0.12272787094116211
> >>> timeit.timeit('mybool(a)', setup='a=list(range(0,300))\ndef mybool(b):\n
> return True if b else False', number=number)
> 0.09898996353149414
> >>> timeit.timeit('bool(a)', setup='a=list(range(0,300))\ndef mybool(b):\n
> return True if b else False', number=number)
> 0.11946702003479004

Indeed; I suspect the cost of allocating memory for bool() where that's not used
in any other case. A boolean is a subclass of integers and requires 24 bytes of memory.

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

An upstream commit landed for this bug.

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

There was an error fetching revisions from git servers. Please try again in a few minutes. If the problem persists, contact Launchpad support.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py
2index bb24d57..5ef9737 100755
3--- a/cloudinit/config/cc_set_passwords.py
4+++ b/cloudinit/config/cc_set_passwords.py
5@@ -68,16 +68,57 @@ import re
6 import sys
7
8 from cloudinit.distros import ug_util
9-from cloudinit import ssh_util
10+from cloudinit import log as logging
11+from cloudinit.ssh_util import update_ssh_config
12 from cloudinit import util
13
14 from string import ascii_letters, digits
15
16+LOG = logging.getLogger(__name__)
17+
18 # We are removing certain 'painful' letters/numbers
19 PW_SET = (''.join([x for x in ascii_letters + digits
20 if x not in 'loLOI01']))
21
22
23+def handle_ssh_pwauth(pw_auth, service_cmd=None, service_name="ssh"):
24+ """Apply sshd PasswordAuthentication changes.
25+
26+ @param pw_auth: config setting from 'pw_auth'.
27+ Best given as True, False, or "unchanged".
28+ @param service_cmd: The service command list (['service'])
29+ @param service_name: The name of the sshd service for the system.
30+
31+ @return: None"""
32+ cfg_name = "PasswordAuthentication"
33+ if service_cmd is None:
34+ service_cmd = ["service"]
35+
36+ if util.is_true(pw_auth):
37+ cfg_val = 'yes'
38+ elif util.is_false(pw_auth):
39+ cfg_val = 'no'
40+ else:
41+ bmsg = "Leaving ssh config '%s' unchanged." % cfg_name
42+ if pw_auth is None or pw_auth.lower() == 'unchanged':
43+ LOG.debug("%s ssh_pwauth=%s", bmsg, pw_auth)
44+ else:
45+ LOG.warning("%s Unrecognized value: ssh_pwauth=%s", bmsg, pw_auth)
46+ return
47+
48+ updated = update_ssh_config({cfg_name: cfg_val})
49+ if not updated:
50+ LOG.debug("No need to restart ssh service, %s not updated.", cfg_name)
51+ return
52+
53+ if 'systemctl' in service_cmd:
54+ cmd = list(service_cmd) + ["restart", service_name]
55+ else:
56+ cmd = list(service_cmd) + [service_name, "restart"]
57+ util.subp(cmd)
58+ LOG.debug("Restarted the ssh daemon.")
59+
60+
61 def handle(_name, cfg, cloud, log, args):
62 if len(args) != 0:
63 # if run from command line, and give args, wipe the chpasswd['list']
64@@ -170,65 +211,9 @@ def handle(_name, cfg, cloud, log, args):
65 if expired_users:
66 log.debug("Expired passwords for: %s users", expired_users)
67
68- change_pwauth = False
69- pw_auth = None
70- if 'ssh_pwauth' in cfg:
71- if util.is_true(cfg['ssh_pwauth']):
72- change_pwauth = True
73- pw_auth = 'yes'
74- elif util.is_false(cfg['ssh_pwauth']):
75- change_pwauth = True
76- pw_auth = 'no'
77- elif str(cfg['ssh_pwauth']).lower() == 'unchanged':
78- log.debug('Leaving auth line unchanged')
79- change_pwauth = False
80- elif not str(cfg['ssh_pwauth']).strip():
81- log.debug('Leaving auth line unchanged')
82- change_pwauth = False
83- elif not cfg['ssh_pwauth']:
84- log.debug('Leaving auth line unchanged')
85- change_pwauth = False
86- else:
87- msg = 'Unrecognized value %s for ssh_pwauth' % cfg['ssh_pwauth']
88- util.logexc(log, msg)
89-
90- if change_pwauth:
91- replaced_auth = False
92-
93- # See: man sshd_config
94- old_lines = ssh_util.parse_ssh_config(ssh_util.DEF_SSHD_CFG)
95- new_lines = []
96- i = 0
97- for (i, line) in enumerate(old_lines):
98- # Keywords are case-insensitive and arguments are case-sensitive
99- if line.key == 'passwordauthentication':
100- log.debug("Replacing auth line %s with %s", i + 1, pw_auth)
101- replaced_auth = True
102- line.value = pw_auth
103- new_lines.append(line)
104-
105- if not replaced_auth:
106- log.debug("Adding new auth line %s", i + 1)
107- replaced_auth = True
108- new_lines.append(ssh_util.SshdConfigLine('',
109- 'PasswordAuthentication',
110- pw_auth))
111-
112- lines = [str(l) for l in new_lines]
113- util.write_file(ssh_util.DEF_SSHD_CFG, "\n".join(lines),
114- copy_mode=True)
115-
116- try:
117- cmd = cloud.distro.init_cmd # Default service
118- cmd.append(cloud.distro.get_option('ssh_svcname', 'ssh'))
119- cmd.append('restart')
120- if 'systemctl' in cmd: # Switch action ordering
121- cmd[1], cmd[2] = cmd[2], cmd[1]
122- cmd = filter(None, cmd) # Remove empty arguments
123- util.subp(cmd)
124- log.debug("Restarted the ssh daemon")
125- except Exception:
126- util.logexc(log, "Restarting of the ssh daemon failed")
127+ handle_ssh_pwauth(
128+ cfg.get('ssh_pwauth'), service_cmd=cloud.distro.init_cmd,
129+ service_name=cloud.distro.get_option('ssh_svcname', 'ssh'))
130
131 if len(errors):
132 log.debug("%s errors occured, re-raising the last one", len(errors))
133diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py
134new file mode 100644
135index 0000000..b051ec8
136--- /dev/null
137+++ b/cloudinit/config/tests/test_set_passwords.py
138@@ -0,0 +1,71 @@
139+# This file is part of cloud-init. See LICENSE file for license information.
140+
141+import mock
142+
143+from cloudinit.config import cc_set_passwords as setpass
144+from cloudinit.tests.helpers import CiTestCase
145+from cloudinit import util
146+
147+MODPATH = "cloudinit.config.cc_set_passwords."
148+
149+
150+class TestHandleSshPwauth(CiTestCase):
151+ """Test cc_set_passwords handling of ssh_pwauth in handle_ssh_pwauth."""
152+
153+ with_logs = True
154+
155+ @mock.patch(MODPATH + "util.subp")
156+ def test_unknown_value_logs_warning(self, m_subp):
157+ setpass.handle_ssh_pwauth("floo")
158+ self.assertIn("Unrecognized value: ssh_pwauth=floo",
159+ self.logs.getvalue())
160+ m_subp.assert_not_called()
161+
162+ @mock.patch(MODPATH + "update_ssh_config", return_value=True)
163+ @mock.patch(MODPATH + "util.subp")
164+ def test_systemctl_as_service_cmd(self, m_subp, m_update_ssh_config):
165+ """If systemctl in service cmd: systemctl restart name."""
166+ setpass.handle_ssh_pwauth(
167+ True, service_cmd=["systemctl"], service_name="myssh")
168+ self.assertEqual(mock.call(["systemctl", "restart", "myssh"]),
169+ m_subp.call_args)
170+
171+ @mock.patch(MODPATH + "update_ssh_config", return_value=True)
172+ @mock.patch(MODPATH + "util.subp")
173+ def test_service_as_service_cmd(self, m_subp, m_update_ssh_config):
174+ """If systemctl in service cmd: systemctl restart name."""
175+ setpass.handle_ssh_pwauth(
176+ True, service_cmd=["service"], service_name="myssh")
177+ self.assertEqual(mock.call(["service", "myssh", "restart"]),
178+ m_subp.call_args)
179+
180+ @mock.patch(MODPATH + "update_ssh_config", return_value=False)
181+ @mock.patch(MODPATH + "util.subp")
182+ def test_not_restarted_if_not_updated(self, m_subp, m_update_ssh_config):
183+ """If config is not updated, then no system restart should be done."""
184+ setpass.handle_ssh_pwauth(True)
185+ m_subp.assert_not_called()
186+ self.assertIn("No need to restart ssh", self.logs.getvalue())
187+
188+ @mock.patch(MODPATH + "update_ssh_config", return_value=True)
189+ @mock.patch(MODPATH + "util.subp")
190+ def test_unchanged_does_nothing(self, m_subp, m_update_ssh_config):
191+ """If 'unchanged', then no updates to config and no restart."""
192+ setpass.handle_ssh_pwauth(
193+ "unchanged", service_cmd=["systemctl"], service_name="myssh")
194+ m_update_ssh_config.assert_not_called()
195+ m_subp.assert_not_called()
196+
197+ @mock.patch(MODPATH + "util.subp")
198+ def test_valid_change_values(self, m_subp):
199+ """If value is a valid changen value, then update should be called."""
200+ upname = MODPATH + "update_ssh_config"
201+ optname = "PasswordAuthentication"
202+ for value in util.FALSE_STRINGS + util.TRUE_STRINGS:
203+ optval = "yes" if value in util.TRUE_STRINGS else "no"
204+ with mock.patch(upname, return_value=False) as m_update:
205+ setpass.handle_ssh_pwauth(value)
206+ m_update.assert_called_with({optname: optval})
207+ m_subp.assert_not_called()
208+
209+# vi: ts=4 expandtab
210diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py
211index 882517f..73c3177 100644
212--- a/cloudinit/ssh_util.py
213+++ b/cloudinit/ssh_util.py
214@@ -279,24 +279,28 @@ class SshdConfigLine(object):
215
216
217 def parse_ssh_config(fname):
218+ if not os.path.isfile(fname):
219+ return []
220+ return parse_ssh_config_lines(util.load_file(fname).splitlines())
221+
222+
223+def parse_ssh_config_lines(lines):
224 # See: man sshd_config
225 # The file contains keyword-argument pairs, one per line.
226 # Lines starting with '#' and empty lines are interpreted as comments.
227 # Note: key-words are case-insensitive and arguments are case-sensitive
228- lines = []
229- if not os.path.isfile(fname):
230- return lines
231- for line in util.load_file(fname).splitlines():
232+ ret = []
233+ for line in lines:
234 line = line.strip()
235 if not line or line.startswith("#"):
236- lines.append(SshdConfigLine(line))
237+ ret.append(SshdConfigLine(line))
238 continue
239 try:
240 key, val = line.split(None, 1)
241 except ValueError:
242 key, val = line.split('=', 1)
243- lines.append(SshdConfigLine(line, key, val))
244- return lines
245+ ret.append(SshdConfigLine(line, key, val))
246+ return ret
247
248
249 def parse_ssh_config_map(fname):
250@@ -310,4 +314,56 @@ def parse_ssh_config_map(fname):
251 ret[line.key] = line.value
252 return ret
253
254+
255+def update_ssh_config(updates, fname=DEF_SSHD_CFG):
256+ """Read fname, and update if changes are necessary.
257+
258+ @param updates: dictionary of desired values {Option: value}
259+ @return: boolean indicating if an update was done."""
260+ lines = parse_ssh_config(fname)
261+ changed = update_ssh_config_lines(lines=lines, updates=updates)
262+ if changed:
263+ util.write_file(
264+ fname, "\n".join([str(l) for l in lines]) + "\n", copy_mode=True)
265+ return len(changed) != 0
266+
267+
268+def update_ssh_config_lines(lines, updates):
269+ """Update the ssh config lines per updates.
270+
271+ @param lines: array of SshdConfigLine. This array is updated in place.
272+ @param updates: dictionary of desired values {Option: value}
273+ @return: A list of keys in updates that were changed."""
274+ found = set()
275+ changed = []
276+
277+ # Keywords are case-insensitive and arguments are case-sensitive
278+ casemap = dict([(k.lower(), k) for k in updates.keys()])
279+
280+ for (i, line) in enumerate(lines, start=1):
281+ if not line.key:
282+ continue
283+ if line.key in casemap:
284+ key = casemap[line.key]
285+ value = updates[key]
286+ found.add(key)
287+ if line.value == value:
288+ LOG.debug("line %d: option %s already set to %s",
289+ i, key, value)
290+ else:
291+ changed.append(key)
292+ LOG.debug("line %d: option %s updated %s -> %s", i,
293+ key, line.value, value)
294+ line.value = value
295+
296+ if len(found) != len(updates):
297+ for key, value in updates.items():
298+ if key in found:
299+ continue
300+ changed.append(key)
301+ lines.append(SshdConfigLine('', key, value))
302+ LOG.debug("line %d: option %s added with %s",
303+ len(lines), key, value)
304+ return changed
305+
306 # vi: ts=4 expandtab
307diff --git a/tests/unittests/test_sshutil.py b/tests/unittests/test_sshutil.py
308index 4c62c8b..73ae897 100644
309--- a/tests/unittests/test_sshutil.py
310+++ b/tests/unittests/test_sshutil.py
311@@ -4,6 +4,7 @@ from mock import patch
312
313 from cloudinit import ssh_util
314 from cloudinit.tests import helpers as test_helpers
315+from cloudinit import util
316
317
318 VALID_CONTENT = {
319@@ -56,7 +57,7 @@ TEST_OPTIONS = (
320 'user \"root\".\';echo;sleep 10"')
321
322
323-class TestAuthKeyLineParser(test_helpers.TestCase):
324+class TestAuthKeyLineParser(test_helpers.CiTestCase):
325
326 def test_simple_parse(self):
327 # test key line with common 3 fields (keytype, base64, comment)
328@@ -126,7 +127,7 @@ class TestAuthKeyLineParser(test_helpers.TestCase):
329 self.assertFalse(key.valid())
330
331
332-class TestUpdateAuthorizedKeys(test_helpers.TestCase):
333+class TestUpdateAuthorizedKeys(test_helpers.CiTestCase):
334
335 def test_new_keys_replace(self):
336 """new entries with the same base64 should replace old."""
337@@ -168,7 +169,7 @@ class TestUpdateAuthorizedKeys(test_helpers.TestCase):
338 self.assertEqual(expected, found)
339
340
341-class TestParseSSHConfig(test_helpers.TestCase):
342+class TestParseSSHConfig(test_helpers.CiTestCase):
343
344 def setUp(self):
345 self.load_file_patch = patch('cloudinit.ssh_util.util.load_file')
346@@ -235,4 +236,94 @@ class TestParseSSHConfig(test_helpers.TestCase):
347 self.assertEqual('foo', ret[0].key)
348 self.assertEqual('bar', ret[0].value)
349
350+
351+class TestUpdateSshConfigLines(test_helpers.CiTestCase):
352+ """Test the update_ssh_config_lines method."""
353+ exlines = [
354+ "#PasswordAuthentication yes",
355+ "UsePAM yes",
356+ "# Comment line",
357+ "AcceptEnv LANG LC_*",
358+ "X11Forwarding no",
359+ ]
360+ pwauth = "PasswordAuthentication"
361+
362+ def check_line(self, line, opt, val):
363+ self.assertEqual(line.key, opt.lower())
364+ self.assertEqual(line.value, val)
365+ self.assertIn(opt, str(line))
366+ self.assertIn(val, str(line))
367+
368+ def test_new_option_added(self):
369+ """A single update of non-existing option."""
370+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
371+ result = ssh_util.update_ssh_config_lines(lines, {'MyKey': 'MyVal'})
372+ self.assertEqual(['MyKey'], result)
373+ self.check_line(lines[-1], "MyKey", "MyVal")
374+
375+ def test_commented_out_not_updated_but_appended(self):
376+ """Implementation does not un-comment and update lines."""
377+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
378+ result = ssh_util.update_ssh_config_lines(lines, {self.pwauth: "no"})
379+ self.assertEqual([self.pwauth], result)
380+ self.check_line(lines[-1], self.pwauth, "no")
381+
382+ def test_single_option_updated(self):
383+ """A single update should have change made and line updated."""
384+ opt, val = ("UsePAM", "no")
385+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
386+ result = ssh_util.update_ssh_config_lines(lines, {opt: val})
387+ self.assertEqual([opt], result)
388+ self.check_line(lines[1], opt, val)
389+
390+ def test_multiple_updates_with_add(self):
391+ """Verify multiple updates some added some changed, some not."""
392+ updates = {"UsePAM": "no", "X11Forwarding": "no", "NewOpt": "newval",
393+ "AcceptEnv": "LANG ADD LC_*"}
394+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
395+ result = ssh_util.update_ssh_config_lines(lines, updates)
396+ self.assertEqual(set(["UsePAM", "NewOpt", "AcceptEnv"]), set(result))
397+ self.check_line(lines[3], "AcceptEnv", updates["AcceptEnv"])
398+
399+ def test_return_empty_if_no_changes(self):
400+ """If there are no changes, then return should be empty list."""
401+ updates = {"UsePAM": "yes"}
402+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
403+ result = ssh_util.update_ssh_config_lines(lines, updates)
404+ self.assertEqual([], result)
405+ self.assertEqual(self.exlines, [str(l) for l in lines])
406+
407+ def test_keycase_not_modified(self):
408+ """Original case of key should not be changed on update.
409+ This behavior is to keep original config as much intact as can be."""
410+ updates = {"usepam": "no"}
411+ lines = ssh_util.parse_ssh_config_lines(list(self.exlines))
412+ result = ssh_util.update_ssh_config_lines(lines, updates)
413+ self.assertEqual(["usepam"], result)
414+ self.assertEqual("UsePAM no", str(lines[1]))
415+
416+
417+class TestUpdateSshConfig(test_helpers.CiTestCase):
418+ cfgdata = '\n'.join(["#Option val", "MyKey ORIG_VAL", ""])
419+
420+ def test_modified(self):
421+ mycfg = self.tmp_path("ssh_config_1")
422+ util.write_file(mycfg, self.cfgdata)
423+ ret = ssh_util.update_ssh_config({"MyKey": "NEW_VAL"}, mycfg)
424+ self.assertTrue(ret)
425+ found = util.load_file(mycfg)
426+ self.assertEqual(self.cfgdata.replace("ORIG_VAL", "NEW_VAL"), found)
427+ # assert there is a newline at end of file (LP: #1677205)
428+ self.assertEqual('\n', found[-1])
429+
430+ def test_not_modified(self):
431+ mycfg = self.tmp_path("ssh_config_2")
432+ util.write_file(mycfg, self.cfgdata)
433+ with patch("cloudinit.ssh_util.util.write_file") as m_write_file:
434+ ret = ssh_util.update_ssh_config({"MyKey": "ORIG_VAL"}, mycfg)
435+ self.assertFalse(ret)
436+ self.assertEqual(self.cfgdata, util.load_file(mycfg))
437+ m_write_file.assert_not_called()
438+
439+
440 # vi: ts=4 expandtab

Subscribers

People subscribed via source and target branches