591b7e
From 60991b1241a5efb585df889d4343007e501fd70c Mon Sep 17 00:00:00 2001
591b7e
From: Eduardo Otubo <otubo@redhat.com>
591b7e
Date: Tue, 5 May 2020 08:08:15 +0200
591b7e
Subject: [PATCH 2/5] Add support for publishing host keys to GCE guest
591b7e
 attributes
591b7e
591b7e
RH-Author: Eduardo Otubo <otubo@redhat.com>
591b7e
Message-id: <20200504085238.25884-3-otubo@redhat.com>
591b7e
Patchwork-id: 96243
591b7e
O-Subject: [RHEL-7.8.z cloud-init PATCH 2/5] Add support for publishing host keys to GCE guest attributes
591b7e
Bugzilla: 1827207
591b7e
RH-Acked-by: Cathy Avery <cavery@redhat.com>
591b7e
RH-Acked-by: Mohammed Gamal <mgamal@redhat.com>
591b7e
RH-Acked-by: Vitaly Kuznetsov <vkuznets@redhat.com>
591b7e
591b7e
commit 155847209e6a3ed5face91a133d8488a703f3f93
591b7e
Author: Rick Wright <rickw@google.com>
591b7e
Date:   Fri Aug 9 17:11:05 2019 +0000
591b7e
591b7e
    Add support for publishing host keys to GCE guest attributes
591b7e
591b7e
    This adds an empty publish_host_keys() method to the default datasource
591b7e
    that is called by cc_ssh.py. This feature can be controlled by the
591b7e
    'ssh_publish_hostkeys' config option. It is enabled by default but can
591b7e
    be disabled by setting 'enabled' to false. Also, a blacklist of key
591b7e
    types is supported.
591b7e
591b7e
    In addition, this change implements ssh_publish_hostkeys() for the GCE
591b7e
    datasource, attempting to write the hostkeys to the instance's guest
591b7e
    attributes. Using these hostkeys for ssh connections is currently
591b7e
    supported by the alpha version of Google's 'gcloud' command-line tool.
591b7e
591b7e
    (On Google Compute Engine, this feature will be enabled by setting the
591b7e
    'enable-guest-attributes' metadata key to 'true' for the
591b7e
    project/instance that you would like to use this feature for. When
591b7e
    connecting to the instance for the first time using 'gcloud compute ssh'
591b7e
    the hostkeys will be read from the guest attributes for the instance and
591b7e
    written to the user's local known_hosts file for Google Compute Engine
591b7e
    instances.)
591b7e
591b7e
Signed-off-by: Eduardo Otubo <otubo@redhat.com>
591b7e
Signed-off-by: Miroslav Rezanina <mrezanin@redhat.com>
591b7e
---
591b7e
 cloudinit/config/cc_ssh.py                  |  55 +++++++++
591b7e
 cloudinit/config/tests/test_ssh.py          | 166 ++++++++++++++++++++++++++++
591b7e
 cloudinit/sources/DataSourceGCE.py          |  22 +++-
591b7e
 cloudinit/sources/__init__.py               |  10 ++
591b7e
 cloudinit/url_helper.py                     |   9 +-
591b7e
 tests/unittests/test_datasource/test_gce.py |  18 +++
591b7e
 6 files changed, 274 insertions(+), 6 deletions(-)
591b7e
591b7e
diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py
591b7e
index f8f7cb3..53f6939 100755
591b7e
--- a/cloudinit/config/cc_ssh.py
591b7e
+++ b/cloudinit/config/cc_ssh.py
591b7e
@@ -91,6 +91,9 @@ public keys.
591b7e
     ssh_authorized_keys:
591b7e
         - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...
591b7e
         - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...
591b7e
+    ssh_publish_hostkeys:
591b7e
+        enabled: <true/false> (Defaults to true)
591b7e
+        blacklist: <list of key types> (Defaults to [dsa])
591b7e
 """
591b7e
 
591b7e
 import glob
591b7e
@@ -104,6 +107,10 @@ from cloudinit import util
591b7e
 
591b7e
 GENERATE_KEY_NAMES = ['rsa', 'dsa', 'ecdsa', 'ed25519']
591b7e
 KEY_FILE_TPL = '/etc/ssh/ssh_host_%s_key'
591b7e
+PUBLISH_HOST_KEYS = True
591b7e
+# Don't publish the dsa hostkey by default since OpenSSH recommends not using
591b7e
+# it.
591b7e
+HOST_KEY_PUBLISH_BLACKLIST = ['dsa']
591b7e
 
591b7e
 CONFIG_KEY_TO_FILE = {}
591b7e
 PRIV_TO_PUB = {}
591b7e
@@ -176,6 +183,23 @@ def handle(_name, cfg, cloud, log, _args):
591b7e
                         util.logexc(log, "Failed generating key type %s to "
591b7e
                                     "file %s", keytype, keyfile)
591b7e
 
591b7e
+    if "ssh_publish_hostkeys" in cfg:
591b7e
+        host_key_blacklist = util.get_cfg_option_list(
591b7e
+            cfg["ssh_publish_hostkeys"], "blacklist",
591b7e
+            HOST_KEY_PUBLISH_BLACKLIST)
591b7e
+        publish_hostkeys = util.get_cfg_option_bool(
591b7e
+            cfg["ssh_publish_hostkeys"], "enabled", PUBLISH_HOST_KEYS)
591b7e
+    else:
591b7e
+        host_key_blacklist = HOST_KEY_PUBLISH_BLACKLIST
591b7e
+        publish_hostkeys = PUBLISH_HOST_KEYS
591b7e
+
591b7e
+    if publish_hostkeys:
591b7e
+        hostkeys = get_public_host_keys(blacklist=host_key_blacklist)
591b7e
+        try:
591b7e
+            cloud.datasource.publish_host_keys(hostkeys)
591b7e
+        except Exception as e:
591b7e
+            util.logexc(log, "Publishing host keys failed!")
591b7e
+
591b7e
     try:
591b7e
         (users, _groups) = ug_util.normalize_users_groups(cfg, cloud.distro)
591b7e
         (user, _user_config) = ug_util.extract_default(users)
591b7e
@@ -209,4 +233,35 @@ def apply_credentials(keys, user, disable_root, disable_root_opts):
591b7e
 
591b7e
     ssh_util.setup_user_keys(keys, 'root', options=key_prefix)
591b7e
 
591b7e
+
591b7e
+def get_public_host_keys(blacklist=None):
591b7e
+    """Read host keys from /etc/ssh/*.pub files and return them as a list.
591b7e
+
591b7e
+    @param blacklist: List of key types to ignore. e.g. ['dsa', 'rsa']
591b7e
+    @returns: List of keys, each formatted as a two-element tuple.
591b7e
+        e.g. [('ssh-rsa', 'AAAAB3Nz...'), ('ssh-ed25519', 'AAAAC3Nx...')]
591b7e
+    """
591b7e
+    public_key_file_tmpl = '%s.pub' % (KEY_FILE_TPL,)
591b7e
+    key_list = []
591b7e
+    blacklist_files = []
591b7e
+    if blacklist:
591b7e
+        # Convert blacklist to filenames:
591b7e
+        # 'dsa' -> '/etc/ssh/ssh_host_dsa_key.pub'
591b7e
+        blacklist_files = [public_key_file_tmpl % (key_type,)
591b7e
+                           for key_type in blacklist]
591b7e
+    # Get list of public key files and filter out blacklisted files.
591b7e
+    file_list = [hostfile for hostfile
591b7e
+                 in glob.glob(public_key_file_tmpl % ('*',))
591b7e
+                 if hostfile not in blacklist_files]
591b7e
+
591b7e
+    # Read host key files, retrieve first two fields as a tuple and
591b7e
+    # append that tuple to key_list.
591b7e
+    for file_name in file_list:
591b7e
+        file_contents = util.load_file(file_name)
591b7e
+        key_data = file_contents.split()
591b7e
+        if key_data and len(key_data) > 1:
591b7e
+            key_list.append(tuple(key_data[:2]))
591b7e
+    return key_list
591b7e
+
591b7e
+
591b7e
 # vi: ts=4 expandtab
591b7e
diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py
591b7e
index c8a4271..e778984 100644
591b7e
--- a/cloudinit/config/tests/test_ssh.py
591b7e
+++ b/cloudinit/config/tests/test_ssh.py
591b7e
@@ -1,5 +1,6 @@
591b7e
 # This file is part of cloud-init. See LICENSE file for license information.
591b7e
 
591b7e
+import os.path
591b7e
 
591b7e
 from cloudinit.config import cc_ssh
591b7e
 from cloudinit import ssh_util
591b7e
@@ -12,6 +13,25 @@ MODPATH = "cloudinit.config.cc_ssh."
591b7e
 class TestHandleSsh(CiTestCase):
591b7e
     """Test cc_ssh handling of ssh config."""
591b7e
 
591b7e
+    def _publish_hostkey_test_setup(self):
591b7e
+        self.test_hostkeys = {
591b7e
+            'dsa': ('ssh-dss', 'AAAAB3NzaC1kc3MAAACB'),
591b7e
+            'ecdsa': ('ecdsa-sha2-nistp256', 'AAAAE2VjZ'),
591b7e
+            'ed25519': ('ssh-ed25519', 'AAAAC3NzaC1lZDI'),
591b7e
+            'rsa': ('ssh-rsa', 'AAAAB3NzaC1yc2EAAA'),
591b7e
+        }
591b7e
+        self.test_hostkey_files = []
591b7e
+        hostkey_tmpdir = self.tmp_dir()
591b7e
+        for key_type in ['dsa', 'ecdsa', 'ed25519', 'rsa']:
591b7e
+            key_data = self.test_hostkeys[key_type]
591b7e
+            filename = 'ssh_host_%s_key.pub' % key_type
591b7e
+            filepath = os.path.join(hostkey_tmpdir, filename)
591b7e
+            self.test_hostkey_files.append(filepath)
591b7e
+            with open(filepath, 'w') as f:
591b7e
+                f.write(' '.join(key_data))
591b7e
+
591b7e
+        cc_ssh.KEY_FILE_TPL = os.path.join(hostkey_tmpdir, 'ssh_host_%s_key')
591b7e
+
591b7e
     def test_apply_credentials_with_user(self, m_setup_keys):
591b7e
         """Apply keys for the given user and root."""
591b7e
         keys = ["key1"]
591b7e
@@ -64,6 +84,7 @@ class TestHandleSsh(CiTestCase):
591b7e
         # Mock os.path.exits to True to short-circuit the key writing logic
591b7e
         m_path_exists.return_value = True
591b7e
         m_nug.return_value = ([], {})
591b7e
+        cc_ssh.PUBLISH_HOST_KEYS = False
591b7e
         cloud = self.tmp_cloud(
591b7e
             distro='ubuntu', metadata={'public-keys': keys})
591b7e
         cc_ssh.handle("name", cfg, cloud, None, None)
591b7e
@@ -149,3 +170,148 @@ class TestHandleSsh(CiTestCase):
591b7e
         self.assertEqual([mock.call(set(keys), user),
591b7e
                           mock.call(set(keys), "root", options="")],
591b7e
                          m_setup_keys.call_args_list)
591b7e
+
591b7e
+    @mock.patch(MODPATH + "glob.glob")
591b7e
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
591b7e
+    @mock.patch(MODPATH + "os.path.exists")
591b7e
+    def test_handle_publish_hostkeys_default(
591b7e
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
591b7e
+        """Test handle with various configs for ssh_publish_hostkeys."""
591b7e
+        self._publish_hostkey_test_setup()
591b7e
+        cc_ssh.PUBLISH_HOST_KEYS = True
591b7e
+        keys = ["key1"]
591b7e
+        user = "clouduser"
591b7e
+        # Return no matching keys for first glob, test keys for second.
591b7e
+        m_glob.side_effect = iter([
591b7e
+                                  [],
591b7e
+                                  self.test_hostkey_files,
591b7e
+                                  ])
591b7e
+        # Mock os.path.exits to True to short-circuit the key writing logic
591b7e
+        m_path_exists.return_value = True
591b7e
+        m_nug.return_value = ({user: {"default": user}}, {})
591b7e
+        cloud = self.tmp_cloud(
591b7e
+            distro='ubuntu', metadata={'public-keys': keys})
591b7e
+        cloud.datasource.publish_host_keys = mock.Mock()
591b7e
+
591b7e
+        cfg = {}
591b7e
+        expected_call = [self.test_hostkeys[key_type] for key_type
591b7e
+                         in ['ecdsa', 'ed25519', 'rsa']]
591b7e
+        cc_ssh.handle("name", cfg, cloud, None, None)
591b7e
+        self.assertEqual([mock.call(expected_call)],
591b7e
+                         cloud.datasource.publish_host_keys.call_args_list)
591b7e
+
591b7e
+    @mock.patch(MODPATH + "glob.glob")
591b7e
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
591b7e
+    @mock.patch(MODPATH + "os.path.exists")
591b7e
+    def test_handle_publish_hostkeys_config_enable(
591b7e
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
591b7e
+        """Test handle with various configs for ssh_publish_hostkeys."""
591b7e
+        self._publish_hostkey_test_setup()
591b7e
+        cc_ssh.PUBLISH_HOST_KEYS = False
591b7e
+        keys = ["key1"]
591b7e
+        user = "clouduser"
591b7e
+        # Return no matching keys for first glob, test keys for second.
591b7e
+        m_glob.side_effect = iter([
591b7e
+                                  [],
591b7e
+                                  self.test_hostkey_files,
591b7e
+                                  ])
591b7e
+        # Mock os.path.exits to True to short-circuit the key writing logic
591b7e
+        m_path_exists.return_value = True
591b7e
+        m_nug.return_value = ({user: {"default": user}}, {})
591b7e
+        cloud = self.tmp_cloud(
591b7e
+            distro='ubuntu', metadata={'public-keys': keys})
591b7e
+        cloud.datasource.publish_host_keys = mock.Mock()
591b7e
+
591b7e
+        cfg = {'ssh_publish_hostkeys': {'enabled': True}}
591b7e
+        expected_call = [self.test_hostkeys[key_type] for key_type
591b7e
+                         in ['ecdsa', 'ed25519', 'rsa']]
591b7e
+        cc_ssh.handle("name", cfg, cloud, None, None)
591b7e
+        self.assertEqual([mock.call(expected_call)],
591b7e
+                         cloud.datasource.publish_host_keys.call_args_list)
591b7e
+
591b7e
+    @mock.patch(MODPATH + "glob.glob")
591b7e
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
591b7e
+    @mock.patch(MODPATH + "os.path.exists")
591b7e
+    def test_handle_publish_hostkeys_config_disable(
591b7e
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
591b7e
+        """Test handle with various configs for ssh_publish_hostkeys."""
591b7e
+        self._publish_hostkey_test_setup()
591b7e
+        cc_ssh.PUBLISH_HOST_KEYS = True
591b7e
+        keys = ["key1"]
591b7e
+        user = "clouduser"
591b7e
+        # Return no matching keys for first glob, test keys for second.
591b7e
+        m_glob.side_effect = iter([
591b7e
+                                  [],
591b7e
+                                  self.test_hostkey_files,
591b7e
+                                  ])
591b7e
+        # Mock os.path.exits to True to short-circuit the key writing logic
591b7e
+        m_path_exists.return_value = True
591b7e
+        m_nug.return_value = ({user: {"default": user}}, {})
591b7e
+        cloud = self.tmp_cloud(
591b7e
+            distro='ubuntu', metadata={'public-keys': keys})
591b7e
+        cloud.datasource.publish_host_keys = mock.Mock()
591b7e
+
591b7e
+        cfg = {'ssh_publish_hostkeys': {'enabled': False}}
591b7e
+        cc_ssh.handle("name", cfg, cloud, None, None)
591b7e
+        self.assertFalse(cloud.datasource.publish_host_keys.call_args_list)
591b7e
+        cloud.datasource.publish_host_keys.assert_not_called()
591b7e
+
591b7e
+    @mock.patch(MODPATH + "glob.glob")
591b7e
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
591b7e
+    @mock.patch(MODPATH + "os.path.exists")
591b7e
+    def test_handle_publish_hostkeys_config_blacklist(
591b7e
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
591b7e
+        """Test handle with various configs for ssh_publish_hostkeys."""
591b7e
+        self._publish_hostkey_test_setup()
591b7e
+        cc_ssh.PUBLISH_HOST_KEYS = True
591b7e
+        keys = ["key1"]
591b7e
+        user = "clouduser"
591b7e
+        # Return no matching keys for first glob, test keys for second.
591b7e
+        m_glob.side_effect = iter([
591b7e
+                                  [],
591b7e
+                                  self.test_hostkey_files,
591b7e
+                                  ])
591b7e
+        # Mock os.path.exits to True to short-circuit the key writing logic
591b7e
+        m_path_exists.return_value = True
591b7e
+        m_nug.return_value = ({user: {"default": user}}, {})
591b7e
+        cloud = self.tmp_cloud(
591b7e
+            distro='ubuntu', metadata={'public-keys': keys})
591b7e
+        cloud.datasource.publish_host_keys = mock.Mock()
591b7e
+
591b7e
+        cfg = {'ssh_publish_hostkeys': {'enabled': True,
591b7e
+                                        'blacklist': ['dsa', 'rsa']}}
591b7e
+        expected_call = [self.test_hostkeys[key_type] for key_type
591b7e
+                         in ['ecdsa', 'ed25519']]
591b7e
+        cc_ssh.handle("name", cfg, cloud, None, None)
591b7e
+        self.assertEqual([mock.call(expected_call)],
591b7e
+                         cloud.datasource.publish_host_keys.call_args_list)
591b7e
+
591b7e
+    @mock.patch(MODPATH + "glob.glob")
591b7e
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
591b7e
+    @mock.patch(MODPATH + "os.path.exists")
591b7e
+    def test_handle_publish_hostkeys_empty_blacklist(
591b7e
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
591b7e
+        """Test handle with various configs for ssh_publish_hostkeys."""
591b7e
+        self._publish_hostkey_test_setup()
591b7e
+        cc_ssh.PUBLISH_HOST_KEYS = True
591b7e
+        keys = ["key1"]
591b7e
+        user = "clouduser"
591b7e
+        # Return no matching keys for first glob, test keys for second.
591b7e
+        m_glob.side_effect = iter([
591b7e
+                                  [],
591b7e
+                                  self.test_hostkey_files,
591b7e
+                                  ])
591b7e
+        # Mock os.path.exits to True to short-circuit the key writing logic
591b7e
+        m_path_exists.return_value = True
591b7e
+        m_nug.return_value = ({user: {"default": user}}, {})
591b7e
+        cloud = self.tmp_cloud(
591b7e
+            distro='ubuntu', metadata={'public-keys': keys})
591b7e
+        cloud.datasource.publish_host_keys = mock.Mock()
591b7e
+
591b7e
+        cfg = {'ssh_publish_hostkeys': {'enabled': True,
591b7e
+                                        'blacklist': []}}
591b7e
+        expected_call = [self.test_hostkeys[key_type] for key_type
591b7e
+                         in ['dsa', 'ecdsa', 'ed25519', 'rsa']]
591b7e
+        cc_ssh.handle("name", cfg, cloud, None, None)
591b7e
+        self.assertEqual([mock.call(expected_call)],
591b7e
+                         cloud.datasource.publish_host_keys.call_args_list)
591b7e
diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
591b7e
index d816262..6cbfbba 100644
591b7e
--- a/cloudinit/sources/DataSourceGCE.py
591b7e
+++ b/cloudinit/sources/DataSourceGCE.py
591b7e
@@ -18,10 +18,13 @@ LOG = logging.getLogger(__name__)
591b7e
 MD_V1_URL = 'http://metadata.google.internal/computeMetadata/v1/'
591b7e
 BUILTIN_DS_CONFIG = {'metadata_url': MD_V1_URL}
591b7e
 REQUIRED_FIELDS = ('instance-id', 'availability-zone', 'local-hostname')
591b7e
+GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
591b7e
+                        'v1/instance/guest-attributes')
591b7e
+HOSTKEY_NAMESPACE = 'hostkeys'
591b7e
+HEADERS = {'Metadata-Flavor': 'Google'}
591b7e
 
591b7e
 
591b7e
 class GoogleMetadataFetcher(object):
591b7e
-    headers = {'Metadata-Flavor': 'Google'}
591b7e
 
591b7e
     def __init__(self, metadata_address):
591b7e
         self.metadata_address = metadata_address
591b7e
@@ -32,7 +35,7 @@ class GoogleMetadataFetcher(object):
591b7e
             url = self.metadata_address + path
591b7e
             if is_recursive:
591b7e
                 url += '/?recursive=True'
591b7e
-            resp = url_helper.readurl(url=url, headers=self.headers)
591b7e
+            resp = url_helper.readurl(url=url, headers=HEADERS)
591b7e
         except url_helper.UrlError as exc:
591b7e
             msg = "url %s raised exception %s"
591b7e
             LOG.debug(msg, path, exc)
591b7e
@@ -90,6 +93,10 @@ class DataSourceGCE(sources.DataSource):
591b7e
         public_keys_data = self.metadata['public-keys-data']
591b7e
         return _parse_public_keys(public_keys_data, self.default_user)
591b7e
 
591b7e
+    def publish_host_keys(self, hostkeys):
591b7e
+        for key in hostkeys:
591b7e
+            _write_host_key_to_guest_attributes(*key)
591b7e
+
591b7e
     def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
591b7e
         # GCE has long FDQN's and has asked for short hostnames.
591b7e
         return self.metadata['local-hostname'].split('.')[0]
591b7e
@@ -103,6 +110,17 @@ class DataSourceGCE(sources.DataSource):
591b7e
         return self.availability_zone.rsplit('-', 1)[0]
591b7e
 
591b7e
 
591b7e
+def _write_host_key_to_guest_attributes(key_type, key_value):
591b7e
+    url = '%s/%s/%s' % (GUEST_ATTRIBUTES_URL, HOSTKEY_NAMESPACE, key_type)
591b7e
+    key_value = key_value.encode('utf-8')
591b7e
+    resp = url_helper.readurl(url=url, data=key_value, headers=HEADERS,
591b7e
+                              request_method='PUT', check_status=False)
591b7e
+    if resp.ok():
591b7e
+        LOG.debug('Wrote %s host key to guest attributes.',  key_type)
591b7e
+    else:
591b7e
+        LOG.debug('Unable to write %s host key to guest attributes.', key_type)
591b7e
+
591b7e
+
591b7e
 def _has_expired(public_key):
591b7e
     # Check whether an SSH key is expired. Public key input is a single SSH
591b7e
     # public key in the GCE specific key format documented here:
591b7e
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
591b7e
index e6966b3..425e703 100644
591b7e
--- a/cloudinit/sources/__init__.py
591b7e
+++ b/cloudinit/sources/__init__.py
591b7e
@@ -474,6 +474,16 @@ class DataSource(object):
591b7e
     def get_public_ssh_keys(self):
591b7e
         return normalize_pubkey_data(self.metadata.get('public-keys'))
591b7e
 
591b7e
+    def publish_host_keys(self, hostkeys):
591b7e
+        """Publish the public SSH host keys (found in /etc/ssh/*.pub).
591b7e
+
591b7e
+        @param hostkeys: List of host key tuples (key_type, key_value),
591b7e
+            where key_type is the first field in the public key file
591b7e
+            (e.g. 'ssh-rsa') and key_value is the key itself
591b7e
+            (e.g. 'AAAAB3NzaC1y...').
591b7e
+        """
591b7e
+        pass
591b7e
+
591b7e
     def _remap_device(self, short_name):
591b7e
         # LP: #611137
591b7e
         # the metadata service may believe that devices are named 'sda'
591b7e
diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
591b7e
index 396d69a..1b0721b 100644
591b7e
--- a/cloudinit/url_helper.py
591b7e
+++ b/cloudinit/url_helper.py
591b7e
@@ -199,18 +199,19 @@ def _get_ssl_args(url, ssl_details):
591b7e
 def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
591b7e
             headers=None, headers_cb=None, ssl_details=None,
591b7e
             check_status=True, allow_redirects=True, exception_cb=None,
591b7e
-            session=None, infinite=False, log_req_resp=True):
591b7e
+            session=None, infinite=False, log_req_resp=True,
591b7e
+            request_method=None):
591b7e
     url = _cleanurl(url)
591b7e
     req_args = {
591b7e
         'url': url,
591b7e
     }
591b7e
     req_args.update(_get_ssl_args(url, ssl_details))
591b7e
     req_args['allow_redirects'] = allow_redirects
591b7e
-    req_args['method'] = 'GET'
591b7e
+    if not request_method:
591b7e
+        request_method = 'POST' if data else 'GET'
591b7e
+    req_args['method'] = request_method
591b7e
     if timeout is not None:
591b7e
         req_args['timeout'] = max(float(timeout), 0)
591b7e
-    if data:
591b7e
-        req_args['method'] = 'POST'
591b7e
     # It doesn't seem like config
591b7e
     # was added in older library versions (or newer ones either), thus we
591b7e
     # need to manually do the retries if it wasn't...
591b7e
diff --git a/tests/unittests/test_datasource/test_gce.py b/tests/unittests/test_datasource/test_gce.py
591b7e
index 41176c6..67744d3 100644
591b7e
--- a/tests/unittests/test_datasource/test_gce.py
591b7e
+++ b/tests/unittests/test_datasource/test_gce.py
591b7e
@@ -55,6 +55,8 @@ GCE_USER_DATA_TEXT = {
591b7e
 HEADERS = {'Metadata-Flavor': 'Google'}
591b7e
 MD_URL_RE = re.compile(
591b7e
     r'http://metadata.google.internal/computeMetadata/v1/.*')
591b7e
+GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
591b7e
+                        'v1/instance/guest-attributes/hostkeys/')
591b7e
 
591b7e
 
591b7e
 def _set_mock_metadata(gce_meta=None):
591b7e
@@ -341,4 +343,20 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
591b7e
             public_key_data, default_user='default')
591b7e
         self.assertEqual(sorted(found), sorted(expected))
591b7e
 
591b7e
+    @mock.patch("cloudinit.url_helper.readurl")
591b7e
+    def test_publish_host_keys(self, m_readurl):
591b7e
+        hostkeys = [('ssh-rsa', 'asdfasdf'),
591b7e
+                    ('ssh-ed25519', 'qwerqwer')]
591b7e
+        readurl_expected_calls = [
591b7e
+            mock.call(check_status=False, data=b'asdfasdf', headers=HEADERS,
591b7e
+                      request_method='PUT',
591b7e
+                      url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-rsa')),
591b7e
+            mock.call(check_status=False, data=b'qwerqwer', headers=HEADERS,
591b7e
+                      request_method='PUT',
591b7e
+                      url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-ed25519')),
591b7e
+        ]
591b7e
+        self.ds.publish_host_keys(hostkeys)
591b7e
+        m_readurl.assert_has_calls(readurl_expected_calls, any_order=True)
591b7e
+
591b7e
+
591b7e
 # vi: ts=4 expandtab
591b7e
-- 
591b7e
1.8.3.1
591b7e