Merge ~wrigri/cloud-init:ssh-hostkey-publish into cloud-init:master

Proposed by Rick Wright on 2019-07-19
Status: Merged
Approved by: Dan Watkins on 2019-08-09
Approved revision: b924e32b3f37775b8eeb131d72dcef22a63bd713
Merge reported by: Server Team CI bot
Merged at revision: not available
Proposed branch: ~wrigri/cloud-init:ssh-hostkey-publish
Merge into: cloud-init:master
Diff against target: 419 lines (+274/-6)
6 files modified
cloudinit/config/cc_ssh.py (+55/-0)
cloudinit/config/tests/test_ssh.py (+166/-0)
cloudinit/sources/DataSourceGCE.py (+20/-2)
cloudinit/sources/__init__.py (+10/-0)
cloudinit/url_helper.py (+5/-4)
tests/unittests/test_datasource/test_gce.py (+18/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve on 2019-08-09
Dan Watkins Approve on 2019-08-09
Ryan Harper 2019-07-19 Needs Information on 2019-07-24
Review via email: mp+370348@code.launchpad.net

Commit message

Add support for publishing host keys to GCE guest attributes

This adds an empty publish_host_keys() method to the default datasource
that is called by cc_ssh.py. This feature can be controlled by the
'ssh_publish_hostkeys' config option. It is enabled by default but can
be disabled by setting 'enabled' to false. Also, a blacklist of key
types is supported.

In addition, this change implements ssh_publish_hostkeys() for the GCE
datasource, attempting to write the hostkeys to the instance's guest
attributes. Using these hostkeys for ssh connections is currently
supported by the alpha version of Google's 'gcloud' command-line tool.

(On Google Compute Engine, this feature will be enabled by setting the
'enable-guest-attributes' metadata key to 'true' for the
project/instance that you would like to use this feature for. When
connecting to the instance for the first time using 'gcloud compute ssh'
the hostkeys will be read from the guest attributes for the instance and
written to the user's local known_hosts file for Google Compute Engine
instances.)

To post a comment you must log in.
Rick Wright (wrigri) wrote :

One additional change that could be made to this would be to move the _get_public_host_keys function out the DataSourceGCE.py and into a util library so that I could be used by others. I'm open to having it in either place.

Ryan Harper (raharper) wrote :

Thanks for submitting this.

review: Needs Information
Rick Wright (wrigri) wrote :

A couple fixes and a couple questions before making the other fixes.

Ryan Harper (raharper) :
Dan Watkins (daniel-thewatkins) wrote :

Hey Rick,

Thanks for submitting this change! I really like what we're trying to enable here, and I think this is a good first pass.

I have a few inline code comments, most of which are fairly minor/mechanical, but I'd like to draw specific attention to my comment on publish_host_keys in DataSourceGCE.py, as that has some higher-level, architectural comments.

Cheers,

Dan

Rick Wright (wrigri) wrote :

Thanks for the feedback, please see my responses below. I haven't made any changes yet, so these are just comments/questions to continue the discussion.

Dan Watkins (daniel-thewatkins) wrote :

Thanks for your thoughtful responses, Rick. Responses inline.

Rick Wright (wrigri) wrote :

I think I have enough to do the restructuring. I'll probably have updated code early next week.

Dan Watkins (daniel-thewatkins) wrote :

OK, great! Thanks again for the work. :)

Rick Wright (wrigri) wrote :

OK, I think I have addressed the previous comments. I still want to do a bit of manual testing on a VM to make sure things work as expected in a live environment, but I think it's ready for another look.

Dan Watkins (daniel-thewatkins) wrote :

OK, this looks really good now, thanks for that refactor! I've included some inline comments on style nits that I bumped on as I went through, but the only one I'd _really_ like to see fixed is my request for some docs on line 55 (of the diff).

That just leaves us with the question of defaults. I'm double-checking with other folks on the team, but I'm leaning towards what you have currently being good.

Rick Wright (wrigri) wrote :

Thanks for the additional feedback. I added some docstrings/comments and fixed the style issues.

Dan Watkins (daniel-thewatkins) wrote :

This looks great, thanks for all the work, Rick!

(I've moved the Description into the Commit Message so our autolander doesn't get confused (and so we don't lose that information in Launchpad somewhere).)

review: Approve
review: Approve (continuous-integration)

Commit message lints:
- Line #2 has 223 too many characters. Line starts with: "This adds an empty publish_host_keys()"...- Line #4 has 202 too many characters. Line starts with: "In addition, this change"...

review: Needs Fixing
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py
2index f8f7cb3..53f6939 100755
3--- a/cloudinit/config/cc_ssh.py
4+++ b/cloudinit/config/cc_ssh.py
5@@ -91,6 +91,9 @@ public keys.
6 ssh_authorized_keys:
7 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...
8 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...
9+ ssh_publish_hostkeys:
10+ enabled: <true/false> (Defaults to true)
11+ blacklist: <list of key types> (Defaults to [dsa])
12 """
13
14 import glob
15@@ -104,6 +107,10 @@ from cloudinit import util
16
17 GENERATE_KEY_NAMES = ['rsa', 'dsa', 'ecdsa', 'ed25519']
18 KEY_FILE_TPL = '/etc/ssh/ssh_host_%s_key'
19+PUBLISH_HOST_KEYS = True
20+# Don't publish the dsa hostkey by default since OpenSSH recommends not using
21+# it.
22+HOST_KEY_PUBLISH_BLACKLIST = ['dsa']
23
24 CONFIG_KEY_TO_FILE = {}
25 PRIV_TO_PUB = {}
26@@ -176,6 +183,23 @@ def handle(_name, cfg, cloud, log, _args):
27 util.logexc(log, "Failed generating key type %s to "
28 "file %s", keytype, keyfile)
29
30+ if "ssh_publish_hostkeys" in cfg:
31+ host_key_blacklist = util.get_cfg_option_list(
32+ cfg["ssh_publish_hostkeys"], "blacklist",
33+ HOST_KEY_PUBLISH_BLACKLIST)
34+ publish_hostkeys = util.get_cfg_option_bool(
35+ cfg["ssh_publish_hostkeys"], "enabled", PUBLISH_HOST_KEYS)
36+ else:
37+ host_key_blacklist = HOST_KEY_PUBLISH_BLACKLIST
38+ publish_hostkeys = PUBLISH_HOST_KEYS
39+
40+ if publish_hostkeys:
41+ hostkeys = get_public_host_keys(blacklist=host_key_blacklist)
42+ try:
43+ cloud.datasource.publish_host_keys(hostkeys)
44+ except Exception as e:
45+ util.logexc(log, "Publishing host keys failed!")
46+
47 try:
48 (users, _groups) = ug_util.normalize_users_groups(cfg, cloud.distro)
49 (user, _user_config) = ug_util.extract_default(users)
50@@ -209,4 +233,35 @@ def apply_credentials(keys, user, disable_root, disable_root_opts):
51
52 ssh_util.setup_user_keys(keys, 'root', options=key_prefix)
53
54+
55+def get_public_host_keys(blacklist=None):
56+ """Read host keys from /etc/ssh/*.pub files and return them as a list.
57+
58+ @param blacklist: List of key types to ignore. e.g. ['dsa', 'rsa']
59+ @returns: List of keys, each formatted as a two-element tuple.
60+ e.g. [('ssh-rsa', 'AAAAB3Nz...'), ('ssh-ed25519', 'AAAAC3Nx...')]
61+ """
62+ public_key_file_tmpl = '%s.pub' % (KEY_FILE_TPL,)
63+ key_list = []
64+ blacklist_files = []
65+ if blacklist:
66+ # Convert blacklist to filenames:
67+ # 'dsa' -> '/etc/ssh/ssh_host_dsa_key.pub'
68+ blacklist_files = [public_key_file_tmpl % (key_type,)
69+ for key_type in blacklist]
70+ # Get list of public key files and filter out blacklisted files.
71+ file_list = [hostfile for hostfile
72+ in glob.glob(public_key_file_tmpl % ('*',))
73+ if hostfile not in blacklist_files]
74+
75+ # Read host key files, retrieve first two fields as a tuple and
76+ # append that tuple to key_list.
77+ for file_name in file_list:
78+ file_contents = util.load_file(file_name)
79+ key_data = file_contents.split()
80+ if key_data and len(key_data) > 1:
81+ key_list.append(tuple(key_data[:2]))
82+ return key_list
83+
84+
85 # vi: ts=4 expandtab
86diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py
87index c8a4271..e778984 100644
88--- a/cloudinit/config/tests/test_ssh.py
89+++ b/cloudinit/config/tests/test_ssh.py
90@@ -1,5 +1,6 @@
91 # This file is part of cloud-init. See LICENSE file for license information.
92
93+import os.path
94
95 from cloudinit.config import cc_ssh
96 from cloudinit import ssh_util
97@@ -12,6 +13,25 @@ MODPATH = "cloudinit.config.cc_ssh."
98 class TestHandleSsh(CiTestCase):
99 """Test cc_ssh handling of ssh config."""
100
101+ def _publish_hostkey_test_setup(self):
102+ self.test_hostkeys = {
103+ 'dsa': ('ssh-dss', 'AAAAB3NzaC1kc3MAAACB'),
104+ 'ecdsa': ('ecdsa-sha2-nistp256', 'AAAAE2VjZ'),
105+ 'ed25519': ('ssh-ed25519', 'AAAAC3NzaC1lZDI'),
106+ 'rsa': ('ssh-rsa', 'AAAAB3NzaC1yc2EAAA'),
107+ }
108+ self.test_hostkey_files = []
109+ hostkey_tmpdir = self.tmp_dir()
110+ for key_type in ['dsa', 'ecdsa', 'ed25519', 'rsa']:
111+ key_data = self.test_hostkeys[key_type]
112+ filename = 'ssh_host_%s_key.pub' % key_type
113+ filepath = os.path.join(hostkey_tmpdir, filename)
114+ self.test_hostkey_files.append(filepath)
115+ with open(filepath, 'w') as f:
116+ f.write(' '.join(key_data))
117+
118+ cc_ssh.KEY_FILE_TPL = os.path.join(hostkey_tmpdir, 'ssh_host_%s_key')
119+
120 def test_apply_credentials_with_user(self, m_setup_keys):
121 """Apply keys for the given user and root."""
122 keys = ["key1"]
123@@ -64,6 +84,7 @@ class TestHandleSsh(CiTestCase):
124 # Mock os.path.exits to True to short-circuit the key writing logic
125 m_path_exists.return_value = True
126 m_nug.return_value = ([], {})
127+ cc_ssh.PUBLISH_HOST_KEYS = False
128 cloud = self.tmp_cloud(
129 distro='ubuntu', metadata={'public-keys': keys})
130 cc_ssh.handle("name", cfg, cloud, None, None)
131@@ -149,3 +170,148 @@ class TestHandleSsh(CiTestCase):
132 self.assertEqual([mock.call(set(keys), user),
133 mock.call(set(keys), "root", options="")],
134 m_setup_keys.call_args_list)
135+
136+ @mock.patch(MODPATH + "glob.glob")
137+ @mock.patch(MODPATH + "ug_util.normalize_users_groups")
138+ @mock.patch(MODPATH + "os.path.exists")
139+ def test_handle_publish_hostkeys_default(
140+ self, m_path_exists, m_nug, m_glob, m_setup_keys):
141+ """Test handle with various configs for ssh_publish_hostkeys."""
142+ self._publish_hostkey_test_setup()
143+ cc_ssh.PUBLISH_HOST_KEYS = True
144+ keys = ["key1"]
145+ user = "clouduser"
146+ # Return no matching keys for first glob, test keys for second.
147+ m_glob.side_effect = iter([
148+ [],
149+ self.test_hostkey_files,
150+ ])
151+ # Mock os.path.exits to True to short-circuit the key writing logic
152+ m_path_exists.return_value = True
153+ m_nug.return_value = ({user: {"default": user}}, {})
154+ cloud = self.tmp_cloud(
155+ distro='ubuntu', metadata={'public-keys': keys})
156+ cloud.datasource.publish_host_keys = mock.Mock()
157+
158+ cfg = {}
159+ expected_call = [self.test_hostkeys[key_type] for key_type
160+ in ['ecdsa', 'ed25519', 'rsa']]
161+ cc_ssh.handle("name", cfg, cloud, None, None)
162+ self.assertEqual([mock.call(expected_call)],
163+ cloud.datasource.publish_host_keys.call_args_list)
164+
165+ @mock.patch(MODPATH + "glob.glob")
166+ @mock.patch(MODPATH + "ug_util.normalize_users_groups")
167+ @mock.patch(MODPATH + "os.path.exists")
168+ def test_handle_publish_hostkeys_config_enable(
169+ self, m_path_exists, m_nug, m_glob, m_setup_keys):
170+ """Test handle with various configs for ssh_publish_hostkeys."""
171+ self._publish_hostkey_test_setup()
172+ cc_ssh.PUBLISH_HOST_KEYS = False
173+ keys = ["key1"]
174+ user = "clouduser"
175+ # Return no matching keys for first glob, test keys for second.
176+ m_glob.side_effect = iter([
177+ [],
178+ self.test_hostkey_files,
179+ ])
180+ # Mock os.path.exits to True to short-circuit the key writing logic
181+ m_path_exists.return_value = True
182+ m_nug.return_value = ({user: {"default": user}}, {})
183+ cloud = self.tmp_cloud(
184+ distro='ubuntu', metadata={'public-keys': keys})
185+ cloud.datasource.publish_host_keys = mock.Mock()
186+
187+ cfg = {'ssh_publish_hostkeys': {'enabled': True}}
188+ expected_call = [self.test_hostkeys[key_type] for key_type
189+ in ['ecdsa', 'ed25519', 'rsa']]
190+ cc_ssh.handle("name", cfg, cloud, None, None)
191+ self.assertEqual([mock.call(expected_call)],
192+ cloud.datasource.publish_host_keys.call_args_list)
193+
194+ @mock.patch(MODPATH + "glob.glob")
195+ @mock.patch(MODPATH + "ug_util.normalize_users_groups")
196+ @mock.patch(MODPATH + "os.path.exists")
197+ def test_handle_publish_hostkeys_config_disable(
198+ self, m_path_exists, m_nug, m_glob, m_setup_keys):
199+ """Test handle with various configs for ssh_publish_hostkeys."""
200+ self._publish_hostkey_test_setup()
201+ cc_ssh.PUBLISH_HOST_KEYS = True
202+ keys = ["key1"]
203+ user = "clouduser"
204+ # Return no matching keys for first glob, test keys for second.
205+ m_glob.side_effect = iter([
206+ [],
207+ self.test_hostkey_files,
208+ ])
209+ # Mock os.path.exits to True to short-circuit the key writing logic
210+ m_path_exists.return_value = True
211+ m_nug.return_value = ({user: {"default": user}}, {})
212+ cloud = self.tmp_cloud(
213+ distro='ubuntu', metadata={'public-keys': keys})
214+ cloud.datasource.publish_host_keys = mock.Mock()
215+
216+ cfg = {'ssh_publish_hostkeys': {'enabled': False}}
217+ cc_ssh.handle("name", cfg, cloud, None, None)
218+ self.assertFalse(cloud.datasource.publish_host_keys.call_args_list)
219+ cloud.datasource.publish_host_keys.assert_not_called()
220+
221+ @mock.patch(MODPATH + "glob.glob")
222+ @mock.patch(MODPATH + "ug_util.normalize_users_groups")
223+ @mock.patch(MODPATH + "os.path.exists")
224+ def test_handle_publish_hostkeys_config_blacklist(
225+ self, m_path_exists, m_nug, m_glob, m_setup_keys):
226+ """Test handle with various configs for ssh_publish_hostkeys."""
227+ self._publish_hostkey_test_setup()
228+ cc_ssh.PUBLISH_HOST_KEYS = True
229+ keys = ["key1"]
230+ user = "clouduser"
231+ # Return no matching keys for first glob, test keys for second.
232+ m_glob.side_effect = iter([
233+ [],
234+ self.test_hostkey_files,
235+ ])
236+ # Mock os.path.exits to True to short-circuit the key writing logic
237+ m_path_exists.return_value = True
238+ m_nug.return_value = ({user: {"default": user}}, {})
239+ cloud = self.tmp_cloud(
240+ distro='ubuntu', metadata={'public-keys': keys})
241+ cloud.datasource.publish_host_keys = mock.Mock()
242+
243+ cfg = {'ssh_publish_hostkeys': {'enabled': True,
244+ 'blacklist': ['dsa', 'rsa']}}
245+ expected_call = [self.test_hostkeys[key_type] for key_type
246+ in ['ecdsa', 'ed25519']]
247+ cc_ssh.handle("name", cfg, cloud, None, None)
248+ self.assertEqual([mock.call(expected_call)],
249+ cloud.datasource.publish_host_keys.call_args_list)
250+
251+ @mock.patch(MODPATH + "glob.glob")
252+ @mock.patch(MODPATH + "ug_util.normalize_users_groups")
253+ @mock.patch(MODPATH + "os.path.exists")
254+ def test_handle_publish_hostkeys_empty_blacklist(
255+ self, m_path_exists, m_nug, m_glob, m_setup_keys):
256+ """Test handle with various configs for ssh_publish_hostkeys."""
257+ self._publish_hostkey_test_setup()
258+ cc_ssh.PUBLISH_HOST_KEYS = True
259+ keys = ["key1"]
260+ user = "clouduser"
261+ # Return no matching keys for first glob, test keys for second.
262+ m_glob.side_effect = iter([
263+ [],
264+ self.test_hostkey_files,
265+ ])
266+ # Mock os.path.exits to True to short-circuit the key writing logic
267+ m_path_exists.return_value = True
268+ m_nug.return_value = ({user: {"default": user}}, {})
269+ cloud = self.tmp_cloud(
270+ distro='ubuntu', metadata={'public-keys': keys})
271+ cloud.datasource.publish_host_keys = mock.Mock()
272+
273+ cfg = {'ssh_publish_hostkeys': {'enabled': True,
274+ 'blacklist': []}}
275+ expected_call = [self.test_hostkeys[key_type] for key_type
276+ in ['dsa', 'ecdsa', 'ed25519', 'rsa']]
277+ cc_ssh.handle("name", cfg, cloud, None, None)
278+ self.assertEqual([mock.call(expected_call)],
279+ cloud.datasource.publish_host_keys.call_args_list)
280diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
281index d816262..6cbfbba 100644
282--- a/cloudinit/sources/DataSourceGCE.py
283+++ b/cloudinit/sources/DataSourceGCE.py
284@@ -18,10 +18,13 @@ LOG = logging.getLogger(__name__)
285 MD_V1_URL = 'http://metadata.google.internal/computeMetadata/v1/'
286 BUILTIN_DS_CONFIG = {'metadata_url': MD_V1_URL}
287 REQUIRED_FIELDS = ('instance-id', 'availability-zone', 'local-hostname')
288+GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
289+ 'v1/instance/guest-attributes')
290+HOSTKEY_NAMESPACE = 'hostkeys'
291+HEADERS = {'Metadata-Flavor': 'Google'}
292
293
294 class GoogleMetadataFetcher(object):
295- headers = {'Metadata-Flavor': 'Google'}
296
297 def __init__(self, metadata_address):
298 self.metadata_address = metadata_address
299@@ -32,7 +35,7 @@ class GoogleMetadataFetcher(object):
300 url = self.metadata_address + path
301 if is_recursive:
302 url += '/?recursive=True'
303- resp = url_helper.readurl(url=url, headers=self.headers)
304+ resp = url_helper.readurl(url=url, headers=HEADERS)
305 except url_helper.UrlError as exc:
306 msg = "url %s raised exception %s"
307 LOG.debug(msg, path, exc)
308@@ -90,6 +93,10 @@ class DataSourceGCE(sources.DataSource):
309 public_keys_data = self.metadata['public-keys-data']
310 return _parse_public_keys(public_keys_data, self.default_user)
311
312+ def publish_host_keys(self, hostkeys):
313+ for key in hostkeys:
314+ _write_host_key_to_guest_attributes(*key)
315+
316 def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
317 # GCE has long FDQN's and has asked for short hostnames.
318 return self.metadata['local-hostname'].split('.')[0]
319@@ -103,6 +110,17 @@ class DataSourceGCE(sources.DataSource):
320 return self.availability_zone.rsplit('-', 1)[0]
321
322
323+def _write_host_key_to_guest_attributes(key_type, key_value):
324+ url = '%s/%s/%s' % (GUEST_ATTRIBUTES_URL, HOSTKEY_NAMESPACE, key_type)
325+ key_value = key_value.encode('utf-8')
326+ resp = url_helper.readurl(url=url, data=key_value, headers=HEADERS,
327+ request_method='PUT', check_status=False)
328+ if resp.ok():
329+ LOG.debug('Wrote %s host key to guest attributes.', key_type)
330+ else:
331+ LOG.debug('Unable to write %s host key to guest attributes.', key_type)
332+
333+
334 def _has_expired(public_key):
335 # Check whether an SSH key is expired. Public key input is a single SSH
336 # public key in the GCE specific key format documented here:
337diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
338index c2baccd..a319322 100644
339--- a/cloudinit/sources/__init__.py
340+++ b/cloudinit/sources/__init__.py
341@@ -491,6 +491,16 @@ class DataSource(object):
342 def get_public_ssh_keys(self):
343 return normalize_pubkey_data(self.metadata.get('public-keys'))
344
345+ def publish_host_keys(self, hostkeys):
346+ """Publish the public SSH host keys (found in /etc/ssh/*.pub).
347+
348+ @param hostkeys: List of host key tuples (key_type, key_value),
349+ where key_type is the first field in the public key file
350+ (e.g. 'ssh-rsa') and key_value is the key itself
351+ (e.g. 'AAAAB3NzaC1y...').
352+ """
353+ pass
354+
355 def _remap_device(self, short_name):
356 # LP: #611137
357 # the metadata service may believe that devices are named 'sda'
358diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
359index 0af0d9e..44ee61d 100644
360--- a/cloudinit/url_helper.py
361+++ b/cloudinit/url_helper.py
362@@ -199,18 +199,19 @@ def _get_ssl_args(url, ssl_details):
363 def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
364 headers=None, headers_cb=None, ssl_details=None,
365 check_status=True, allow_redirects=True, exception_cb=None,
366- session=None, infinite=False, log_req_resp=True):
367+ session=None, infinite=False, log_req_resp=True,
368+ request_method=None):
369 url = _cleanurl(url)
370 req_args = {
371 'url': url,
372 }
373 req_args.update(_get_ssl_args(url, ssl_details))
374 req_args['allow_redirects'] = allow_redirects
375- req_args['method'] = 'GET'
376+ if not request_method:
377+ request_method = 'POST' if data else 'GET'
378+ req_args['method'] = request_method
379 if timeout is not None:
380 req_args['timeout'] = max(float(timeout), 0)
381- if data:
382- req_args['method'] = 'POST'
383 # It doesn't seem like config
384 # was added in older library versions (or newer ones either), thus we
385 # need to manually do the retries if it wasn't...
386diff --git a/tests/unittests/test_datasource/test_gce.py b/tests/unittests/test_datasource/test_gce.py
387index 41176c6..67744d3 100644
388--- a/tests/unittests/test_datasource/test_gce.py
389+++ b/tests/unittests/test_datasource/test_gce.py
390@@ -55,6 +55,8 @@ GCE_USER_DATA_TEXT = {
391 HEADERS = {'Metadata-Flavor': 'Google'}
392 MD_URL_RE = re.compile(
393 r'http://metadata.google.internal/computeMetadata/v1/.*')
394+GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
395+ 'v1/instance/guest-attributes/hostkeys/')
396
397
398 def _set_mock_metadata(gce_meta=None):
399@@ -341,4 +343,20 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
400 public_key_data, default_user='default')
401 self.assertEqual(sorted(found), sorted(expected))
402
403+ @mock.patch("cloudinit.url_helper.readurl")
404+ def test_publish_host_keys(self, m_readurl):
405+ hostkeys = [('ssh-rsa', 'asdfasdf'),
406+ ('ssh-ed25519', 'qwerqwer')]
407+ readurl_expected_calls = [
408+ mock.call(check_status=False, data=b'asdfasdf', headers=HEADERS,
409+ request_method='PUT',
410+ url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-rsa')),
411+ mock.call(check_status=False, data=b'qwerqwer', headers=HEADERS,
412+ request_method='PUT',
413+ url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-ed25519')),
414+ ]
415+ self.ds.publish_host_keys(hostkeys)
416+ m_readurl.assert_has_calls(readurl_expected_calls, any_order=True)
417+
418+
419 # vi: ts=4 expandtab

Subscribers

People subscribed via source and target branches