c36ff1
From 68f058e8d20a499f74bc78af8e0c6a90ca57ae20 Mon Sep 17 00:00:00 2001
c36ff1
From: Thomas Stringer <thstring@microsoft.com>
c36ff1
Date: Mon, 26 Apr 2021 09:41:38 -0400
c36ff1
Subject: [PATCH 5/7] Azure: Retrieve username and hostname from IMDS (#865)
c36ff1
c36ff1
RH-Author: Eduardo Otubo <otubo@redhat.com>
c36ff1
RH-MergeRequest: 18: Add support for userdata on Azure from IMDS
c36ff1
RH-Commit: [5/7] 6a768d31e63e5f00dae0fad2712a7618d62b0879 (otubo/cloud-init-src)
c36ff1
RH-Bugzilla: 2042351
c36ff1
RH-Acked-by: Miroslav Rezanina <mrezanin@redhat.com>
c36ff1
RH-Acked-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
c36ff1
c36ff1
This change allows us to retrieve the username and hostname from
c36ff1
IMDS instead of having to rely on the mounted OVF.
c36ff1
---
c36ff1
 cloudinit/sources/DataSourceAzure.py          | 149 ++++++++++++++----
c36ff1
 tests/unittests/test_datasource/test_azure.py |  87 +++++++++-
c36ff1
 2 files changed, 205 insertions(+), 31 deletions(-)
c36ff1
c36ff1
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
c36ff1
index 39e67c4f..6d7954ee 100755
c36ff1
--- a/cloudinit/sources/DataSourceAzure.py
c36ff1
+++ b/cloudinit/sources/DataSourceAzure.py
c36ff1
@@ -5,6 +5,7 @@
c36ff1
 # This file is part of cloud-init. See LICENSE file for license information.
c36ff1
 
c36ff1
 import base64
c36ff1
+from collections import namedtuple
c36ff1
 import contextlib
c36ff1
 import crypt
c36ff1
 from functools import partial
c36ff1
@@ -25,6 +26,7 @@ from cloudinit.net import device_driver
c36ff1
 from cloudinit.net.dhcp import EphemeralDHCPv4
c36ff1
 from cloudinit import sources
c36ff1
 from cloudinit.sources.helpers import netlink
c36ff1
+from cloudinit import ssh_util
c36ff1
 from cloudinit import subp
c36ff1
 from cloudinit.url_helper import UrlError, readurl, retry_on_url_exc
c36ff1
 from cloudinit import util
c36ff1
@@ -80,7 +82,12 @@ AGENT_SEED_DIR = '/var/lib/waagent'
c36ff1
 IMDS_TIMEOUT_IN_SECONDS = 2
c36ff1
 IMDS_URL = "http://169.254.169.254/metadata"
c36ff1
 IMDS_VER_MIN = "2019-06-01"
c36ff1
-IMDS_VER_WANT = "2020-09-01"
c36ff1
+IMDS_VER_WANT = "2020-10-01"
c36ff1
+
c36ff1
+
c36ff1
+# This holds SSH key data including if the source was
c36ff1
+# from IMDS, as well as the SSH key data itself.
c36ff1
+SSHKeys = namedtuple("SSHKeys", ("keys_from_imds", "ssh_keys"))
c36ff1
 
c36ff1
 
c36ff1
 class metadata_type(Enum):
c36ff1
@@ -391,6 +398,8 @@ class DataSourceAzure(sources.DataSource):
c36ff1
         """Return the subplatform metadata source details."""
c36ff1
         if self.seed.startswith('/dev'):
c36ff1
             subplatform_type = 'config-disk'
c36ff1
+        elif self.seed.lower() == 'imds':
c36ff1
+            subplatform_type = 'imds'
c36ff1
         else:
c36ff1
             subplatform_type = 'seed-dir'
c36ff1
         return '%s (%s)' % (subplatform_type, self.seed)
c36ff1
@@ -433,9 +442,11 @@ class DataSourceAzure(sources.DataSource):
c36ff1
 
c36ff1
         found = None
c36ff1
         reprovision = False
c36ff1
+        ovf_is_accessible = True
c36ff1
         reprovision_after_nic_attach = False
c36ff1
         for cdev in candidates:
c36ff1
             try:
c36ff1
+                LOG.debug("cdev: %s", cdev)
c36ff1
                 if cdev == "IMDS":
c36ff1
                     ret = None
c36ff1
                     reprovision = True
c36ff1
@@ -462,8 +473,18 @@ class DataSourceAzure(sources.DataSource):
c36ff1
                 raise sources.InvalidMetaDataException(msg)
c36ff1
             except util.MountFailedError:
c36ff1
                 report_diagnostic_event(
c36ff1
-                    '%s was not mountable' % cdev, logger_func=LOG.warning)
c36ff1
-                continue
c36ff1
+                    '%s was not mountable' % cdev, logger_func=LOG.debug)
c36ff1
+                cdev = 'IMDS'
c36ff1
+                ovf_is_accessible = False
c36ff1
+                empty_md = {'local-hostname': ''}
c36ff1
+                empty_cfg = dict(
c36ff1
+                    system_info=dict(
c36ff1
+                        default_user=dict(
c36ff1
+                            name=''
c36ff1
+                        )
c36ff1
+                    )
c36ff1
+                )
c36ff1
+                ret = (empty_md, '', empty_cfg, {})
c36ff1
 
c36ff1
             report_diagnostic_event("Found provisioning metadata in %s" % cdev,
c36ff1
                                     logger_func=LOG.debug)
c36ff1
@@ -490,6 +511,10 @@ class DataSourceAzure(sources.DataSource):
c36ff1
                 self.fallback_interface,
c36ff1
                 retries=10
c36ff1
             )
c36ff1
+            if not imds_md and not ovf_is_accessible:
c36ff1
+                msg = 'No OVF or IMDS available'
c36ff1
+                report_diagnostic_event(msg)
c36ff1
+                raise sources.InvalidMetaDataException(msg)
c36ff1
             (md, userdata_raw, cfg, files) = ret
c36ff1
             self.seed = cdev
c36ff1
             crawled_data.update({
c36ff1
@@ -498,6 +523,21 @@ class DataSourceAzure(sources.DataSource):
c36ff1
                 'metadata': util.mergemanydict(
c36ff1
                     [md, {'imds': imds_md}]),
c36ff1
                 'userdata_raw': userdata_raw})
c36ff1
+            imds_username = _username_from_imds(imds_md)
c36ff1
+            imds_hostname = _hostname_from_imds(imds_md)
c36ff1
+            imds_disable_password = _disable_password_from_imds(imds_md)
c36ff1
+            if imds_username:
c36ff1
+                LOG.debug('Username retrieved from IMDS: %s', imds_username)
c36ff1
+                cfg['system_info']['default_user']['name'] = imds_username
c36ff1
+            if imds_hostname:
c36ff1
+                LOG.debug('Hostname retrieved from IMDS: %s', imds_hostname)
c36ff1
+                crawled_data['metadata']['local-hostname'] = imds_hostname
c36ff1
+            if imds_disable_password:
c36ff1
+                LOG.debug(
c36ff1
+                    'Disable password retrieved from IMDS: %s',
c36ff1
+                    imds_disable_password
c36ff1
+                )
c36ff1
+                crawled_data['metadata']['disable_password'] = imds_disable_password  # noqa: E501
c36ff1
             found = cdev
c36ff1
 
c36ff1
             report_diagnostic_event(
c36ff1
@@ -676,6 +716,13 @@ class DataSourceAzure(sources.DataSource):
c36ff1
 
c36ff1
     @azure_ds_telemetry_reporter
c36ff1
     def get_public_ssh_keys(self):
c36ff1
+        """
c36ff1
+        Retrieve public SSH keys.
c36ff1
+        """
c36ff1
+
c36ff1
+        return self._get_public_ssh_keys_and_source().ssh_keys
c36ff1
+
c36ff1
+    def _get_public_ssh_keys_and_source(self):
c36ff1
         """
c36ff1
         Try to get the ssh keys from IMDS first, and if that fails
c36ff1
         (i.e. IMDS is unavailable) then fallback to getting the ssh
c36ff1
@@ -685,30 +732,50 @@ class DataSourceAzure(sources.DataSource):
c36ff1
         advantage, so this is a strong preference. But we must keep
c36ff1
         OVF as a second option for environments that don't have IMDS.
c36ff1
         """
c36ff1
+
c36ff1
         LOG.debug('Retrieving public SSH keys')
c36ff1
         ssh_keys = []
c36ff1
+        keys_from_imds = True
c36ff1
+        LOG.debug('Attempting to get SSH keys from IMDS')
c36ff1
         try:
c36ff1
-            raise KeyError(
c36ff1
-                "Not using public SSH keys from IMDS"
c36ff1
-            )
c36ff1
-            # pylint:disable=unreachable
c36ff1
             ssh_keys = [
c36ff1
                 public_key['keyData']
c36ff1
                 for public_key
c36ff1
                 in self.metadata['imds']['compute']['publicKeys']
c36ff1
             ]
c36ff1
-            LOG.debug('Retrieved SSH keys from IMDS')
c36ff1
+            for key in ssh_keys:
c36ff1
+                if not _key_is_openssh_formatted(key=key):
c36ff1
+                    keys_from_imds = False
c36ff1
+                    break
c36ff1
+
c36ff1
+            if not keys_from_imds:
c36ff1
+                log_msg = 'Keys not in OpenSSH format, using OVF'
c36ff1
+            else:
c36ff1
+                log_msg = 'Retrieved {} keys from IMDS'.format(
c36ff1
+                    len(ssh_keys)
c36ff1
+                    if ssh_keys is not None
c36ff1
+                    else 0
c36ff1
+                )
c36ff1
         except KeyError:
c36ff1
             log_msg = 'Unable to get keys from IMDS, falling back to OVF'
c36ff1
+            keys_from_imds = False
c36ff1
+        finally:
c36ff1
             report_diagnostic_event(log_msg, logger_func=LOG.debug)
c36ff1
+
c36ff1
+        if not keys_from_imds:
c36ff1
+            LOG.debug('Attempting to get SSH keys from OVF')
c36ff1
             try:
c36ff1
                 ssh_keys = self.metadata['public-keys']
c36ff1
-                LOG.debug('Retrieved keys from OVF')
c36ff1
+                log_msg = 'Retrieved {} keys from OVF'.format(len(ssh_keys))
c36ff1
             except KeyError:
c36ff1
                 log_msg = 'No keys available from OVF'
c36ff1
+            finally:
c36ff1
                 report_diagnostic_event(log_msg, logger_func=LOG.debug)
c36ff1
 
c36ff1
-        return ssh_keys
c36ff1
+        return SSHKeys(
c36ff1
+            keys_from_imds=keys_from_imds,
c36ff1
+            ssh_keys=ssh_keys
c36ff1
+        )
c36ff1
 
c36ff1
     def get_config_obj(self):
c36ff1
         return self.cfg
c36ff1
@@ -1325,30 +1392,21 @@ class DataSourceAzure(sources.DataSource):
c36ff1
         self.bounce_network_with_azure_hostname()
c36ff1
 
c36ff1
         pubkey_info = None
c36ff1
-        try:
c36ff1
-            raise KeyError(
c36ff1
-                "Not using public SSH keys from IMDS"
c36ff1
-            )
c36ff1
-            # pylint:disable=unreachable
c36ff1
-            public_keys = self.metadata['imds']['compute']['publicKeys']
c36ff1
-            LOG.debug(
c36ff1
-                'Successfully retrieved %s key(s) from IMDS',
c36ff1
-                len(public_keys)
c36ff1
-                if public_keys is not None
c36ff1
+        ssh_keys_and_source = self._get_public_ssh_keys_and_source()
c36ff1
+
c36ff1
+        if not ssh_keys_and_source.keys_from_imds:
c36ff1
+            pubkey_info = self.cfg.get('_pubkeys', None)
c36ff1
+            log_msg = 'Retrieved {} fingerprints from OVF'.format(
c36ff1
+                len(pubkey_info)
c36ff1
+                if pubkey_info is not None
c36ff1
                 else 0
c36ff1
             )
c36ff1
-        except KeyError:
c36ff1
-            LOG.debug(
c36ff1
-                'Unable to retrieve SSH keys from IMDS during '
c36ff1
-                'negotiation, falling back to OVF'
c36ff1
-            )
c36ff1
-            pubkey_info = self.cfg.get('_pubkeys', None)
c36ff1
+            report_diagnostic_event(log_msg, logger_func=LOG.debug)
c36ff1
 
c36ff1
         metadata_func = partial(get_metadata_from_fabric,
c36ff1
                                 fallback_lease_file=self.
c36ff1
                                 dhclient_lease_file,
c36ff1
-                                pubkey_info=pubkey_info,
c36ff1
-                                iso_dev=self.iso_dev)
c36ff1
+                                pubkey_info=pubkey_info)
c36ff1
 
c36ff1
         LOG.debug("negotiating with fabric via agent command %s",
c36ff1
                   self.ds_cfg['agent_command'])
c36ff1
@@ -1404,6 +1462,41 @@ class DataSourceAzure(sources.DataSource):
c36ff1
         return self.metadata.get('imds', {}).get('compute', {}).get('location')
c36ff1
 
c36ff1
 
c36ff1
+def _username_from_imds(imds_data):
c36ff1
+    try:
c36ff1
+        return imds_data['compute']['osProfile']['adminUsername']
c36ff1
+    except KeyError:
c36ff1
+        return None
c36ff1
+
c36ff1
+
c36ff1
+def _hostname_from_imds(imds_data):
c36ff1
+    try:
c36ff1
+        return imds_data['compute']['osProfile']['computerName']
c36ff1
+    except KeyError:
c36ff1
+        return None
c36ff1
+
c36ff1
+
c36ff1
+def _disable_password_from_imds(imds_data):
c36ff1
+    try:
c36ff1
+        return imds_data['compute']['osProfile']['disablePasswordAuthentication'] == 'true'  # noqa: E501
c36ff1
+    except KeyError:
c36ff1
+        return None
c36ff1
+
c36ff1
+
c36ff1
+def _key_is_openssh_formatted(key):
c36ff1
+    """
c36ff1
+    Validate whether or not the key is OpenSSH-formatted.
c36ff1
+    """
c36ff1
+
c36ff1
+    parser = ssh_util.AuthKeyLineParser()
c36ff1
+    try:
c36ff1
+        akl = parser.parse(key)
c36ff1
+    except TypeError:
c36ff1
+        return False
c36ff1
+
c36ff1
+    return akl.keytype is not None
c36ff1
+
c36ff1
+
c36ff1
 def _partitions_on_device(devpath, maxnum=16):
c36ff1
     # return a list of tuples (ptnum, path) for each part on devpath
c36ff1
     for suff in ("-part", "p", ""):
c36ff1
diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
c36ff1
index 320fa857..d9817d84 100644
c36ff1
--- a/tests/unittests/test_datasource/test_azure.py
c36ff1
+++ b/tests/unittests/test_datasource/test_azure.py
c36ff1
@@ -108,7 +108,7 @@ NETWORK_METADATA = {
c36ff1
         "zone": "",
c36ff1
         "publicKeys": [
c36ff1
             {
c36ff1
-                "keyData": "key1",
c36ff1
+                "keyData": "ssh-rsa key1",
c36ff1
                 "path": "path1"
c36ff1
             }
c36ff1
         ]
c36ff1
@@ -1761,8 +1761,29 @@ scbus-1 on xpt0 bus 0
c36ff1
         dsrc.get_data()
c36ff1
         dsrc.setup(True)
c36ff1
         ssh_keys = dsrc.get_public_ssh_keys()
c36ff1
-        # Temporarily alter this test so that SSH public keys
c36ff1
-        # from IMDS are *not* going to be in use to fix a regression.
c36ff1
+        self.assertEqual(ssh_keys, ["ssh-rsa key1"])
c36ff1
+        self.assertEqual(m_parse_certificates.call_count, 0)
c36ff1
+
c36ff1
+    @mock.patch(
c36ff1
+        'cloudinit.sources.helpers.azure.OpenSSLManager.parse_certificates')
c36ff1
+    @mock.patch(MOCKPATH + 'get_metadata_from_imds')
c36ff1
+    def test_get_public_ssh_keys_with_no_openssh_format(
c36ff1
+            self,
c36ff1
+            m_get_metadata_from_imds,
c36ff1
+            m_parse_certificates):
c36ff1
+        imds_data = copy.deepcopy(NETWORK_METADATA)
c36ff1
+        imds_data['compute']['publicKeys'][0]['keyData'] = 'no-openssh-format'
c36ff1
+        m_get_metadata_from_imds.return_value = imds_data
c36ff1
+        sys_cfg = {'datasource': {'Azure': {'apply_network_config': True}}}
c36ff1
+        odata = {'HostName': "myhost", 'UserName': "myuser"}
c36ff1
+        data = {
c36ff1
+            'ovfcontent': construct_valid_ovf_env(data=odata),
c36ff1
+            'sys_cfg': sys_cfg
c36ff1
+        }
c36ff1
+        dsrc = self._get_ds(data)
c36ff1
+        dsrc.get_data()
c36ff1
+        dsrc.setup(True)
c36ff1
+        ssh_keys = dsrc.get_public_ssh_keys()
c36ff1
         self.assertEqual(ssh_keys, [])
c36ff1
         self.assertEqual(m_parse_certificates.call_count, 0)
c36ff1
 
c36ff1
@@ -1818,6 +1839,66 @@ scbus-1 on xpt0 bus 0
c36ff1
         self.assertIsNotNone(dsrc.metadata)
c36ff1
         self.assertFalse(dsrc.failed_desired_api_version)
c36ff1
 
c36ff1
+    @mock.patch(MOCKPATH + 'get_metadata_from_imds')
c36ff1
+    def test_hostname_from_imds(self, m_get_metadata_from_imds):
c36ff1
+        sys_cfg = {'datasource': {'Azure': {'apply_network_config': True}}}
c36ff1
+        odata = {'HostName': "myhost", 'UserName': "myuser"}
c36ff1
+        data = {
c36ff1
+            'ovfcontent': construct_valid_ovf_env(data=odata),
c36ff1
+            'sys_cfg': sys_cfg
c36ff1
+        }
c36ff1
+        imds_data_with_os_profile = copy.deepcopy(NETWORK_METADATA)
c36ff1
+        imds_data_with_os_profile["compute"]["osProfile"] = dict(
c36ff1
+            adminUsername="username1",
c36ff1
+            computerName="hostname1",
c36ff1
+            disablePasswordAuthentication="true"
c36ff1
+        )
c36ff1
+        m_get_metadata_from_imds.return_value = imds_data_with_os_profile
c36ff1
+        dsrc = self._get_ds(data)
c36ff1
+        dsrc.get_data()
c36ff1
+        self.assertEqual(dsrc.metadata["local-hostname"], "hostname1")
c36ff1
+
c36ff1
+    @mock.patch(MOCKPATH + 'get_metadata_from_imds')
c36ff1
+    def test_username_from_imds(self, m_get_metadata_from_imds):
c36ff1
+        sys_cfg = {'datasource': {'Azure': {'apply_network_config': True}}}
c36ff1
+        odata = {'HostName': "myhost", 'UserName': "myuser"}
c36ff1
+        data = {
c36ff1
+            'ovfcontent': construct_valid_ovf_env(data=odata),
c36ff1
+            'sys_cfg': sys_cfg
c36ff1
+        }
c36ff1
+        imds_data_with_os_profile = copy.deepcopy(NETWORK_METADATA)
c36ff1
+        imds_data_with_os_profile["compute"]["osProfile"] = dict(
c36ff1
+            adminUsername="username1",
c36ff1
+            computerName="hostname1",
c36ff1
+            disablePasswordAuthentication="true"
c36ff1
+        )
c36ff1
+        m_get_metadata_from_imds.return_value = imds_data_with_os_profile
c36ff1
+        dsrc = self._get_ds(data)
c36ff1
+        dsrc.get_data()
c36ff1
+        self.assertEqual(
c36ff1
+            dsrc.cfg["system_info"]["default_user"]["name"],
c36ff1
+            "username1"
c36ff1
+        )
c36ff1
+
c36ff1
+    @mock.patch(MOCKPATH + 'get_metadata_from_imds')
c36ff1
+    def test_disable_password_from_imds(self, m_get_metadata_from_imds):
c36ff1
+        sys_cfg = {'datasource': {'Azure': {'apply_network_config': True}}}
c36ff1
+        odata = {'HostName': "myhost", 'UserName': "myuser"}
c36ff1
+        data = {
c36ff1
+            'ovfcontent': construct_valid_ovf_env(data=odata),
c36ff1
+            'sys_cfg': sys_cfg
c36ff1
+        }
c36ff1
+        imds_data_with_os_profile = copy.deepcopy(NETWORK_METADATA)
c36ff1
+        imds_data_with_os_profile["compute"]["osProfile"] = dict(
c36ff1
+            adminUsername="username1",
c36ff1
+            computerName="hostname1",
c36ff1
+            disablePasswordAuthentication="true"
c36ff1
+        )
c36ff1
+        m_get_metadata_from_imds.return_value = imds_data_with_os_profile
c36ff1
+        dsrc = self._get_ds(data)
c36ff1
+        dsrc.get_data()
c36ff1
+        self.assertTrue(dsrc.metadata["disable_password"])
c36ff1
+
c36ff1
 
c36ff1
 class TestAzureBounce(CiTestCase):
c36ff1
 
c36ff1
-- 
c36ff1
2.27.0
c36ff1