591b7e
From 68b3718124b63fdf0c077452b559f0fccb01200d Mon Sep 17 00:00:00 2001
591b7e
From: Eduardo Otubo <otubo@redhat.com>
591b7e
Date: Tue, 5 May 2020 08:08:32 +0200
591b7e
Subject: [PATCH 5/5] ec2: Add support for AWS IMDS v2 (session-oriented) (#55)
591b7e
591b7e
RH-Author: Eduardo Otubo <otubo@redhat.com>
591b7e
Message-id: <20200504085238.25884-6-otubo@redhat.com>
591b7e
Patchwork-id: 96245
591b7e
O-Subject: [RHEL-7.8.z cloud-init PATCH 5/5] ec2: Add support for AWS IMDS v2 (session-oriented) (#55)
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 4bc399e0cd0b7e9177f948aecd49f6b8323ff30b
591b7e
Author: Ryan Harper <ryan.harper@canonical.com>
591b7e
Date:   Fri Nov 22 21:05:44 2019 -0600
591b7e
591b7e
    ec2: Add support for AWS IMDS v2 (session-oriented) (#55)
591b7e
591b7e
    * ec2: Add support for AWS IMDS v2 (session-oriented)
591b7e
591b7e
    AWS now supports a new version of fetching Instance Metadata[1].
591b7e
591b7e
    Update cloud-init's ec2 utility functions and update ec2 derived
591b7e
    datasources accordingly.  For DataSourceEc2 (versus ec2-look-alikes)
591b7e
    cloud-init will issue the PUT request to obtain an API token for
591b7e
    the maximum lifetime and then all subsequent interactions with the
591b7e
    IMDS will include the token in the header.
591b7e
591b7e
    If the API token endpoint is unreachable on Ec2 platform, log a
591b7e
    warning and fallback to using IMDS v1 and which does not use
591b7e
    session tokens when communicating with the Instance metadata
591b7e
    service.
591b7e
591b7e
    We handle read errors, typically seen if the IMDS is beyond one
591b7e
    etwork hop (IMDSv2 responses have a ttl=1), by setting the api token
591b7e
    to a disabled value and then using IMDSv1 paths.
591b7e
591b7e
    To support token-based headers, ec2_utils functions were updated
591b7e
    to support custom headers_cb and exception_cb callback functions
591b7e
    so Ec2 could store, or refresh API tokens in the event of token
591b7e
    becoming stale.
591b7e
591b7e
    [1] https://docs.aws.amazon.com/AWSEC2/latest/ \
591b7e
    UserGuide/ec2-instance-metadata.html \
591b7e
    #instance-metadata-v2-how-it-works
591b7e
591b7e
Signed-off-by: Eduardo Otubo <otubo@redhat.com>
591b7e
Signed-off-by: Miroslav Rezanina <mrezanin@redhat.com>
591b7e
---
591b7e
 cloudinit/ec2_utils.py                             |  37 +++--
591b7e
 cloudinit/sources/DataSourceCloudStack.py          |   2 +-
591b7e
 cloudinit/sources/DataSourceEc2.py                 | 166 ++++++++++++++++++---
591b7e
 cloudinit/sources/DataSourceExoscale.py            |   2 +-
591b7e
 cloudinit/sources/DataSourceMAAS.py                |   2 +-
591b7e
 cloudinit/sources/DataSourceOpenStack.py           |   2 +-
591b7e
 cloudinit/url_helper.py                            |  15 +-
591b7e
 tests/unittests/test_datasource/test_cloudstack.py |  21 ++-
591b7e
 tests/unittests/test_datasource/test_ec2.py        |   6 +-
591b7e
 9 files changed, 201 insertions(+), 52 deletions(-)
591b7e
591b7e
diff --git a/cloudinit/ec2_utils.py b/cloudinit/ec2_utils.py
591b7e
index 3b7b17f..57708c1 100644
591b7e
--- a/cloudinit/ec2_utils.py
591b7e
+++ b/cloudinit/ec2_utils.py
591b7e
@@ -134,25 +134,28 @@ class MetadataMaterializer(object):
591b7e
         return joined
591b7e
 
591b7e
 
591b7e
-def _skip_retry_on_codes(status_codes, _request_args, cause):
591b7e
+def skip_retry_on_codes(status_codes, _request_args, cause):
591b7e
     """Returns False if cause.code is in status_codes."""
591b7e
     return cause.code not in status_codes
591b7e
 
591b7e
 
591b7e
 def get_instance_userdata(api_version='latest',
591b7e
                           metadata_address='http://169.254.169.254',
591b7e
-                          ssl_details=None, timeout=5, retries=5):
591b7e
+                          ssl_details=None, timeout=5, retries=5,
591b7e
+                          headers_cb=None, exception_cb=None):
591b7e
     ud_url = url_helper.combine_url(metadata_address, api_version)
591b7e
     ud_url = url_helper.combine_url(ud_url, 'user-data')
591b7e
     user_data = ''
591b7e
     try:
591b7e
-        # It is ok for userdata to not exist (thats why we are stopping if
591b7e
-        # NOT_FOUND occurs) and just in that case returning an empty string.
591b7e
-        exception_cb = functools.partial(_skip_retry_on_codes,
591b7e
-                                         SKIP_USERDATA_CODES)
591b7e
+        if not exception_cb:
591b7e
+            # It is ok for userdata to not exist (thats why we are stopping if
591b7e
+            # NOT_FOUND occurs) and just in that case returning an empty
591b7e
+            # string.
591b7e
+            exception_cb = functools.partial(skip_retry_on_codes,
591b7e
+                                             SKIP_USERDATA_CODES)
591b7e
         response = url_helper.read_file_or_url(
591b7e
             ud_url, ssl_details=ssl_details, timeout=timeout,
591b7e
-            retries=retries, exception_cb=exception_cb)
591b7e
+            retries=retries, exception_cb=exception_cb, headers_cb=headers_cb)
591b7e
         user_data = response.contents
591b7e
     except url_helper.UrlError as e:
591b7e
         if e.code not in SKIP_USERDATA_CODES:
591b7e
@@ -165,11 +168,13 @@ def get_instance_userdata(api_version='latest',
591b7e
 def _get_instance_metadata(tree, api_version='latest',
591b7e
                            metadata_address='http://169.254.169.254',
591b7e
                            ssl_details=None, timeout=5, retries=5,
591b7e
-                           leaf_decoder=None):
591b7e
+                           leaf_decoder=None, headers_cb=None,
591b7e
+                           exception_cb=None):
591b7e
     md_url = url_helper.combine_url(metadata_address, api_version, tree)
591b7e
     caller = functools.partial(
591b7e
         url_helper.read_file_or_url, ssl_details=ssl_details,
591b7e
-        timeout=timeout, retries=retries)
591b7e
+        timeout=timeout, retries=retries, headers_cb=headers_cb,
591b7e
+        exception_cb=exception_cb)
591b7e
 
591b7e
     def mcaller(url):
591b7e
         return caller(url).contents
591b7e
@@ -191,22 +196,28 @@ def _get_instance_metadata(tree, api_version='latest',
591b7e
 def get_instance_metadata(api_version='latest',
591b7e
                           metadata_address='http://169.254.169.254',
591b7e
                           ssl_details=None, timeout=5, retries=5,
591b7e
-                          leaf_decoder=None):
591b7e
+                          leaf_decoder=None, headers_cb=None,
591b7e
+                          exception_cb=None):
591b7e
     # Note, 'meta-data' explicitly has trailing /.
591b7e
     # this is required for CloudStack (LP: #1356855)
591b7e
     return _get_instance_metadata(tree='meta-data/', api_version=api_version,
591b7e
                                   metadata_address=metadata_address,
591b7e
                                   ssl_details=ssl_details, timeout=timeout,
591b7e
-                                  retries=retries, leaf_decoder=leaf_decoder)
591b7e
+                                  retries=retries, leaf_decoder=leaf_decoder,
591b7e
+                                  headers_cb=headers_cb,
591b7e
+                                  exception_cb=exception_cb)
591b7e
 
591b7e
 
591b7e
 def get_instance_identity(api_version='latest',
591b7e
                           metadata_address='http://169.254.169.254',
591b7e
                           ssl_details=None, timeout=5, retries=5,
591b7e
-                          leaf_decoder=None):
591b7e
+                          leaf_decoder=None, headers_cb=None,
591b7e
+                          exception_cb=None):
591b7e
     return _get_instance_metadata(tree='dynamic/instance-identity',
591b7e
                                   api_version=api_version,
591b7e
                                   metadata_address=metadata_address,
591b7e
                                   ssl_details=ssl_details, timeout=timeout,
591b7e
-                                  retries=retries, leaf_decoder=leaf_decoder)
591b7e
+                                  retries=retries, leaf_decoder=leaf_decoder,
591b7e
+                                  headers_cb=headers_cb,
591b7e
+                                  exception_cb=exception_cb)
591b7e
 # vi: ts=4 expandtab
591b7e
diff --git a/cloudinit/sources/DataSourceCloudStack.py b/cloudinit/sources/DataSourceCloudStack.py
591b7e
index d4b758f..6bd2efe 100644
591b7e
--- a/cloudinit/sources/DataSourceCloudStack.py
591b7e
+++ b/cloudinit/sources/DataSourceCloudStack.py
591b7e
@@ -93,7 +93,7 @@ class DataSourceCloudStack(sources.DataSource):
591b7e
         urls = [uhelp.combine_url(self.metadata_address,
591b7e
                                   'latest/meta-data/instance-id')]
591b7e
         start_time = time.time()
591b7e
-        url = uhelp.wait_for_url(
591b7e
+        url, _response = uhelp.wait_for_url(
591b7e
             urls=urls, max_wait=url_params.max_wait_seconds,
591b7e
             timeout=url_params.timeout_seconds, status_cb=LOG.warn)
591b7e
 
591b7e
diff --git a/cloudinit/sources/DataSourceEc2.py b/cloudinit/sources/DataSourceEc2.py
591b7e
index 9ccf2cd..fbe8f3f 100644
591b7e
--- a/cloudinit/sources/DataSourceEc2.py
591b7e
+++ b/cloudinit/sources/DataSourceEc2.py
591b7e
@@ -27,6 +27,10 @@ SKIP_METADATA_URL_CODES = frozenset([uhelp.NOT_FOUND])
591b7e
 STRICT_ID_PATH = ("datasource", "Ec2", "strict_id")
591b7e
 STRICT_ID_DEFAULT = "warn"
591b7e
 
591b7e
+API_TOKEN_ROUTE = 'latest/api/token'
591b7e
+API_TOKEN_DISABLED = '_ec2_disable_api_token'
591b7e
+AWS_TOKEN_TTL_SECONDS = '21600'
591b7e
+
591b7e
 
591b7e
 class CloudNames(object):
591b7e
     ALIYUN = "aliyun"
591b7e
@@ -59,6 +63,7 @@ class DataSourceEc2(sources.DataSource):
591b7e
     url_max_wait = 120
591b7e
     url_timeout = 50
591b7e
 
591b7e
+    _api_token = None  # API token for accessing the metadata service
591b7e
     _network_config = sources.UNSET  # Used to cache calculated network cfg v1
591b7e
 
591b7e
     # Whether we want to get network configuration from the metadata service.
591b7e
@@ -132,11 +137,12 @@ class DataSourceEc2(sources.DataSource):
591b7e
         min_metadata_version.
591b7e
         """
591b7e
         # Assumes metadata service is already up
591b7e
+        url_tmpl = '{0}/{1}/meta-data/instance-id'
591b7e
+        headers = self._get_headers()
591b7e
         for api_ver in self.extended_metadata_versions:
591b7e
-            url = '{0}/{1}/meta-data/instance-id'.format(
591b7e
-                self.metadata_address, api_ver)
591b7e
+            url = url_tmpl.format(self.metadata_address, api_ver)
591b7e
             try:
591b7e
-                resp = uhelp.readurl(url=url)
591b7e
+                resp = uhelp.readurl(url=url, headers=headers)
591b7e
             except uhelp.UrlError as e:
591b7e
                 LOG.debug('url %s raised exception %s', url, e)
591b7e
             else:
591b7e
@@ -156,12 +162,39 @@ class DataSourceEc2(sources.DataSource):
591b7e
                 # setup self.identity. So we need to do that now.
591b7e
                 api_version = self.get_metadata_api_version()
591b7e
                 self.identity = ec2.get_instance_identity(
591b7e
-                    api_version, self.metadata_address).get('document', {})
591b7e
+                    api_version, self.metadata_address,
591b7e
+                    headers_cb=self._get_headers,
591b7e
+                    exception_cb=self._refresh_stale_aws_token_cb).get(
591b7e
+                        'document', {})
591b7e
             return self.identity.get(
591b7e
                 'instanceId', self.metadata['instance-id'])
591b7e
         else:
591b7e
             return self.metadata['instance-id']
591b7e
 
591b7e
+    def _maybe_fetch_api_token(self, mdurls, timeout=None, max_wait=None):
591b7e
+        if self.cloud_name != CloudNames.AWS:
591b7e
+            return
591b7e
+
591b7e
+        urls = []
591b7e
+        url2base = {}
591b7e
+        url_path = API_TOKEN_ROUTE
591b7e
+        request_method = 'PUT'
591b7e
+        for url in mdurls:
591b7e
+            cur = '{0}/{1}'.format(url, url_path)
591b7e
+            urls.append(cur)
591b7e
+            url2base[cur] = url
591b7e
+
591b7e
+        # use the self._status_cb to check for Read errors, which means
591b7e
+        # we can't reach the API token URL, so we should disable IMDSv2
591b7e
+        LOG.debug('Fetching Ec2 IMDSv2 API Token')
591b7e
+        url, response = uhelp.wait_for_url(
591b7e
+            urls=urls, max_wait=1, timeout=1, status_cb=self._status_cb,
591b7e
+            headers_cb=self._get_headers, request_method=request_method)
591b7e
+
591b7e
+        if url and response:
591b7e
+            self._api_token = response
591b7e
+            return url2base[url]
591b7e
+
591b7e
     def wait_for_metadata_service(self):
591b7e
         mcfg = self.ds_cfg
591b7e
 
591b7e
@@ -183,27 +216,39 @@ class DataSourceEc2(sources.DataSource):
591b7e
             LOG.warning("Empty metadata url list! using default list")
591b7e
             mdurls = self.metadata_urls
591b7e
 
591b7e
-        urls = []
591b7e
-        url2base = {}
591b7e
-        for url in mdurls:
591b7e
-            cur = '{0}/{1}/meta-data/instance-id'.format(
591b7e
-                url, self.min_metadata_version)
591b7e
-            urls.append(cur)
591b7e
-            url2base[cur] = url
591b7e
-
591b7e
-        start_time = time.time()
591b7e
-        url = uhelp.wait_for_url(
591b7e
-            urls=urls, max_wait=url_params.max_wait_seconds,
591b7e
-            timeout=url_params.timeout_seconds, status_cb=LOG.warn)
591b7e
-
591b7e
-        if url:
591b7e
-            self.metadata_address = url2base[url]
591b7e
+        # try the api token path first
591b7e
+        metadata_address = self._maybe_fetch_api_token(mdurls)
591b7e
+        if not metadata_address:
591b7e
+            if self._api_token == API_TOKEN_DISABLED:
591b7e
+                LOG.warning('Retrying with IMDSv1')
591b7e
+            # if we can't get a token, use instance-id path
591b7e
+            urls = []
591b7e
+            url2base = {}
591b7e
+            url_path = '{ver}/meta-data/instance-id'.format(
591b7e
+                ver=self.min_metadata_version)
591b7e
+            request_method = 'GET'
591b7e
+            for url in mdurls:
591b7e
+                cur = '{0}/{1}'.format(url, url_path)
591b7e
+                urls.append(cur)
591b7e
+                url2base[cur] = url
591b7e
+
591b7e
+            start_time = time.time()
591b7e
+            url, _ = uhelp.wait_for_url(
591b7e
+                urls=urls, max_wait=url_params.max_wait_seconds,
591b7e
+                timeout=url_params.timeout_seconds, status_cb=LOG.warning,
591b7e
+                headers_cb=self._get_headers, request_method=request_method)
591b7e
+
591b7e
+            if url:
591b7e
+                metadata_address = url2base[url]
591b7e
+
591b7e
+        if metadata_address:
591b7e
+            self.metadata_address = metadata_address
591b7e
             LOG.debug("Using metadata source: '%s'", self.metadata_address)
591b7e
         else:
591b7e
             LOG.critical("Giving up on md from %s after %s seconds",
591b7e
                          urls, int(time.time() - start_time))
591b7e
 
591b7e
-        return bool(url)
591b7e
+        return bool(metadata_address)
591b7e
 
591b7e
     def device_name_to_device(self, name):
591b7e
         # Consult metadata service, that has
591b7e
@@ -349,14 +394,22 @@ class DataSourceEc2(sources.DataSource):
591b7e
             return {}
591b7e
         api_version = self.get_metadata_api_version()
591b7e
         crawled_metadata = {}
591b7e
+        if self.cloud_name == CloudNames.AWS:
591b7e
+            exc_cb = self._refresh_stale_aws_token_cb
591b7e
+            exc_cb_ud = self._skip_or_refresh_stale_aws_token_cb
591b7e
+        else:
591b7e
+            exc_cb = exc_cb_ud = None
591b7e
         try:
591b7e
             crawled_metadata['user-data'] = ec2.get_instance_userdata(
591b7e
-                api_version, self.metadata_address)
591b7e
+                api_version, self.metadata_address,
591b7e
+                headers_cb=self._get_headers, exception_cb=exc_cb_ud)
591b7e
             crawled_metadata['meta-data'] = ec2.get_instance_metadata(
591b7e
-                api_version, self.metadata_address)
591b7e
+                api_version, self.metadata_address,
591b7e
+                headers_cb=self._get_headers, exception_cb=exc_cb)
591b7e
             if self.cloud_name == CloudNames.AWS:
591b7e
                 identity = ec2.get_instance_identity(
591b7e
-                    api_version, self.metadata_address)
591b7e
+                    api_version, self.metadata_address,
591b7e
+                    headers_cb=self._get_headers, exception_cb=exc_cb)
591b7e
                 crawled_metadata['dynamic'] = {'instance-identity': identity}
591b7e
         except Exception:
591b7e
             util.logexc(
591b7e
@@ -366,6 +419,73 @@ class DataSourceEc2(sources.DataSource):
591b7e
         crawled_metadata['_metadata_api_version'] = api_version
591b7e
         return crawled_metadata
591b7e
 
591b7e
+    def _refresh_api_token(self, seconds=AWS_TOKEN_TTL_SECONDS):
591b7e
+        """Request new metadata API token.
591b7e
+        @param seconds: The lifetime of the token in seconds
591b7e
+
591b7e
+        @return: The API token or None if unavailable.
591b7e
+        """
591b7e
+        if self.cloud_name != CloudNames.AWS:
591b7e
+            return None
591b7e
+        LOG.debug("Refreshing Ec2 metadata API token")
591b7e
+        request_header = {'X-aws-ec2-metadata-token-ttl-seconds': seconds}
591b7e
+        token_url = '{}/{}'.format(self.metadata_address, API_TOKEN_ROUTE)
591b7e
+        try:
591b7e
+            response = uhelp.readurl(
591b7e
+                token_url, headers=request_header, request_method="PUT")
591b7e
+        except uhelp.UrlError as e:
591b7e
+            LOG.warning(
591b7e
+                'Unable to get API token: %s raised exception %s',
591b7e
+                token_url, e)
591b7e
+            return None
591b7e
+        return response.contents
591b7e
+
591b7e
+    def _skip_or_refresh_stale_aws_token_cb(self, msg, exception):
591b7e
+        """Callback will not retry on SKIP_USERDATA_CODES or if no token
591b7e
+           is available."""
591b7e
+        retry = ec2.skip_retry_on_codes(
591b7e
+            ec2.SKIP_USERDATA_CODES, msg, exception)
591b7e
+        if not retry:
591b7e
+            return False  # False raises exception
591b7e
+        return self._refresh_stale_aws_token_cb(msg, exception)
591b7e
+
591b7e
+    def _refresh_stale_aws_token_cb(self, msg, exception):
591b7e
+        """Exception handler for Ec2 to refresh token if token is stale."""
591b7e
+        if isinstance(exception, uhelp.UrlError) and exception.code == 401:
591b7e
+            # With _api_token as None, _get_headers will _refresh_api_token.
591b7e
+            LOG.debug("Clearing cached Ec2 API token due to expiry")
591b7e
+            self._api_token = None
591b7e
+        return True  # always retry
591b7e
+
591b7e
+    def _status_cb(self, msg, exc=None):
591b7e
+        LOG.warning(msg)
591b7e
+        if 'Read timed out' in msg:
591b7e
+            LOG.warning('Cannot use Ec2 IMDSv2 API tokens, using IMDSv1')
591b7e
+            self._api_token = API_TOKEN_DISABLED
591b7e
+
591b7e
+    def _get_headers(self, url=''):
591b7e
+        """Return a dict of headers for accessing a url.
591b7e
+
591b7e
+        If _api_token is unset on AWS, attempt to refresh the token via a PUT
591b7e
+        and then return the updated token header.
591b7e
+        """
591b7e
+        if self.cloud_name != CloudNames.AWS or (self._api_token ==
591b7e
+                                                 API_TOKEN_DISABLED):
591b7e
+            return {}
591b7e
+        # Request a 6 hour token if URL is API_TOKEN_ROUTE
591b7e
+        request_token_header = {
591b7e
+            'X-aws-ec2-metadata-token-ttl-seconds': AWS_TOKEN_TTL_SECONDS}
591b7e
+        if API_TOKEN_ROUTE in url:
591b7e
+            return request_token_header
591b7e
+        if not self._api_token:
591b7e
+            # If we don't yet have an API token, get one via a PUT against
591b7e
+            # API_TOKEN_ROUTE. This _api_token may get unset by a 403 due
591b7e
+            # to an invalid or expired token
591b7e
+            self._api_token = self._refresh_api_token()
591b7e
+            if not self._api_token:
591b7e
+                return {}
591b7e
+        return {'X-aws-ec2-metadata-token': self._api_token}
591b7e
+
591b7e
 
591b7e
 class DataSourceEc2Local(DataSourceEc2):
591b7e
     """Datasource run at init-local which sets up network to query metadata.
591b7e
diff --git a/cloudinit/sources/DataSourceExoscale.py b/cloudinit/sources/DataSourceExoscale.py
591b7e
index 4616daa..d59aefd 100644
591b7e
--- a/cloudinit/sources/DataSourceExoscale.py
591b7e
+++ b/cloudinit/sources/DataSourceExoscale.py
591b7e
@@ -61,7 +61,7 @@ class DataSourceExoscale(sources.DataSource):
591b7e
         metadata_url = "{}/{}/meta-data/instance-id".format(
591b7e
             self.metadata_url, self.api_version)
591b7e
 
591b7e
-        url = url_helper.wait_for_url(
591b7e
+        url, _response = url_helper.wait_for_url(
591b7e
             urls=[metadata_url],
591b7e
             max_wait=self.url_max_wait,
591b7e
             timeout=self.url_timeout,
591b7e
diff --git a/cloudinit/sources/DataSourceMAAS.py b/cloudinit/sources/DataSourceMAAS.py
591b7e
index 61aa6d7..517913a 100644
591b7e
--- a/cloudinit/sources/DataSourceMAAS.py
591b7e
+++ b/cloudinit/sources/DataSourceMAAS.py
591b7e
@@ -136,7 +136,7 @@ class DataSourceMAAS(sources.DataSource):
591b7e
             url = url[:-1]
591b7e
         check_url = "%s/%s/meta-data/instance-id" % (url, MD_VERSION)
591b7e
         urls = [check_url]
591b7e
-        url = self.oauth_helper.wait_for_url(
591b7e
+        url, _response = self.oauth_helper.wait_for_url(
591b7e
             urls=urls, max_wait=max_wait, timeout=timeout)
591b7e
 
591b7e
         if url:
591b7e
diff --git a/cloudinit/sources/DataSourceOpenStack.py b/cloudinit/sources/DataSourceOpenStack.py
591b7e
index 4a01524..7a5e71b 100644
591b7e
--- a/cloudinit/sources/DataSourceOpenStack.py
591b7e
+++ b/cloudinit/sources/DataSourceOpenStack.py
591b7e
@@ -76,7 +76,7 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
591b7e
 
591b7e
         url_params = self.get_url_params()
591b7e
         start_time = time.time()
591b7e
-        avail_url = url_helper.wait_for_url(
591b7e
+        avail_url, _response = url_helper.wait_for_url(
591b7e
             urls=md_urls, max_wait=url_params.max_wait_seconds,
591b7e
             timeout=url_params.timeout_seconds)
591b7e
         if avail_url:
591b7e
diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
591b7e
index 1b0721b..a951b8b 100644
591b7e
--- a/cloudinit/url_helper.py
591b7e
+++ b/cloudinit/url_helper.py
591b7e
@@ -101,7 +101,7 @@ def read_file_or_url(url, timeout=5, retries=10,
591b7e
             raise UrlError(cause=e, code=code, headers=None, url=url)
591b7e
         return FileResponse(file_path, contents=contents)
591b7e
     else:
591b7e
-        return readurl(url, timeout=timeout, retries=retries, headers=headers,
591b7e
+        return readurl(url, timeout=timeout, retries=retries,
591b7e
                        headers_cb=headers_cb, data=data,
591b7e
                        sec_between=sec_between, ssl_details=ssl_details,
591b7e
                        exception_cb=exception_cb)
591b7e
@@ -310,7 +310,7 @@ def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
591b7e
 
591b7e
 def wait_for_url(urls, max_wait=None, timeout=None,
591b7e
                  status_cb=None, headers_cb=None, sleep_time=1,
591b7e
-                 exception_cb=None, sleep_time_cb=None):
591b7e
+                 exception_cb=None, sleep_time_cb=None, request_method=None):
591b7e
     """
591b7e
     urls:      a list of urls to try
591b7e
     max_wait:  roughly the maximum time to wait before giving up
591b7e
@@ -325,6 +325,8 @@ def wait_for_url(urls, max_wait=None, timeout=None,
591b7e
                   'exception', the exception that occurred.
591b7e
     sleep_time_cb: call method with 2 arguments (response, loop_n) that
591b7e
                    generates the next sleep time.
591b7e
+    request_method: indicate the type of HTTP request, GET, PUT, or POST
591b7e
+    returns: tuple of (url, response contents), on failure, (False, None)
591b7e
 
591b7e
     the idea of this routine is to wait for the EC2 metdata service to
591b7e
     come up.  On both Eucalyptus and EC2 we have seen the case where
591b7e
@@ -381,8 +383,9 @@ def wait_for_url(urls, max_wait=None, timeout=None,
591b7e
                 else:
591b7e
                     headers = {}
591b7e
 
591b7e
-                response = readurl(url, headers=headers, timeout=timeout,
591b7e
-                                   check_status=False)
591b7e
+                response = readurl(
591b7e
+                    url, headers=headers, timeout=timeout,
591b7e
+                    check_status=False, request_method=request_method)
591b7e
                 if not response.contents:
591b7e
                     reason = "empty response [%s]" % (response.code)
591b7e
                     url_exc = UrlError(ValueError(reason), code=response.code,
591b7e
@@ -392,7 +395,7 @@ def wait_for_url(urls, max_wait=None, timeout=None,
591b7e
                     url_exc = UrlError(ValueError(reason), code=response.code,
591b7e
                                        headers=response.headers, url=url)
591b7e
                 else:
591b7e
-                    return url
591b7e
+                    return url, response.contents
591b7e
             except UrlError as e:
591b7e
                 reason = "request error [%s]" % e
591b7e
                 url_exc = e
591b7e
@@ -421,7 +424,7 @@ def wait_for_url(urls, max_wait=None, timeout=None,
591b7e
                   sleep_time)
591b7e
         time.sleep(sleep_time)
591b7e
 
591b7e
-    return False
591b7e
+    return False, None
591b7e
 
591b7e
 
591b7e
 class OauthUrlHelper(object):
591b7e
diff --git a/tests/unittests/test_datasource/test_cloudstack.py b/tests/unittests/test_datasource/test_cloudstack.py
591b7e
index d6d2d6b..83c2f75 100644
591b7e
--- a/tests/unittests/test_datasource/test_cloudstack.py
591b7e
+++ b/tests/unittests/test_datasource/test_cloudstack.py
591b7e
@@ -10,6 +10,9 @@ from cloudinit.tests.helpers import CiTestCase, ExitStack, mock
591b7e
 import os
591b7e
 import time
591b7e
 
591b7e
+MOD_PATH = 'cloudinit.sources.DataSourceCloudStack'
591b7e
+DS_PATH = MOD_PATH + '.DataSourceCloudStack'
591b7e
+
591b7e
 
591b7e
 class TestCloudStackPasswordFetching(CiTestCase):
591b7e
 
591b7e
@@ -17,7 +20,7 @@ class TestCloudStackPasswordFetching(CiTestCase):
591b7e
         super(TestCloudStackPasswordFetching, self).setUp()
591b7e
         self.patches = ExitStack()
591b7e
         self.addCleanup(self.patches.close)
591b7e
-        mod_name = 'cloudinit.sources.DataSourceCloudStack'
591b7e
+        mod_name = MOD_PATH
591b7e
         self.patches.enter_context(mock.patch('{0}.ec2'.format(mod_name)))
591b7e
         self.patches.enter_context(mock.patch('{0}.uhelp'.format(mod_name)))
591b7e
         default_gw = "192.201.20.0"
591b7e
@@ -56,7 +59,9 @@ class TestCloudStackPasswordFetching(CiTestCase):
591b7e
         ds.get_data()
591b7e
         self.assertEqual({}, ds.get_config_obj())
591b7e
 
591b7e
-    def test_password_sets_password(self):
591b7e
+    @mock.patch(DS_PATH + '.wait_for_metadata_service')
591b7e
+    def test_password_sets_password(self, m_wait):
591b7e
+        m_wait.return_value = True
591b7e
         password = 'SekritSquirrel'
591b7e
         self._set_password_server_response(password)
591b7e
         ds = DataSourceCloudStack(
591b7e
@@ -64,7 +69,9 @@ class TestCloudStackPasswordFetching(CiTestCase):
591b7e
         ds.get_data()
591b7e
         self.assertEqual(password, ds.get_config_obj()['password'])
591b7e
 
591b7e
-    def test_bad_request_doesnt_stop_ds_from_working(self):
591b7e
+    @mock.patch(DS_PATH + '.wait_for_metadata_service')
591b7e
+    def test_bad_request_doesnt_stop_ds_from_working(self, m_wait):
591b7e
+        m_wait.return_value = True
591b7e
         self._set_password_server_response('bad_request')
591b7e
         ds = DataSourceCloudStack(
591b7e
             {}, None, helpers.Paths({'run_dir': self.tmp}))
591b7e
@@ -79,7 +86,9 @@ class TestCloudStackPasswordFetching(CiTestCase):
591b7e
                     request_types.append(arg.split()[1])
591b7e
         self.assertEqual(expected_request_types, request_types)
591b7e
 
591b7e
-    def test_valid_response_means_password_marked_as_saved(self):
591b7e
+    @mock.patch(DS_PATH + '.wait_for_metadata_service')
591b7e
+    def test_valid_response_means_password_marked_as_saved(self, m_wait):
591b7e
+        m_wait.return_value = True
591b7e
         password = 'SekritSquirrel'
591b7e
         subp = self._set_password_server_response(password)
591b7e
         ds = DataSourceCloudStack(
591b7e
@@ -92,7 +101,9 @@ class TestCloudStackPasswordFetching(CiTestCase):
591b7e
         subp = self._set_password_server_response(response_string)
591b7e
         ds = DataSourceCloudStack(
591b7e
             {}, None, helpers.Paths({'run_dir': self.tmp}))
591b7e
-        ds.get_data()
591b7e
+        with mock.patch(DS_PATH + '.wait_for_metadata_service') as m_wait:
591b7e
+            m_wait.return_value = True
591b7e
+            ds.get_data()
591b7e
         self.assertRequestTypesSent(subp, ['send_my_password'])
591b7e
 
591b7e
     def test_password_not_saved_if_empty(self):
591b7e
diff --git a/tests/unittests/test_datasource/test_ec2.py b/tests/unittests/test_datasource/test_ec2.py
591b7e
index 1a5956d..5c5c787 100644
591b7e
--- a/tests/unittests/test_datasource/test_ec2.py
591b7e
+++ b/tests/unittests/test_datasource/test_ec2.py
591b7e
@@ -191,7 +191,9 @@ def register_mock_metaserver(base_url, data):
591b7e
             register(base_url, 'not found', status=404)
591b7e
 
591b7e
     def myreg(*argc, **kwargs):
591b7e
-        return httpretty.register_uri(httpretty.GET, *argc, **kwargs)
591b7e
+        url = argc[0]
591b7e
+        method = httpretty.PUT if ec2.API_TOKEN_ROUTE in url else httpretty.GET
591b7e
+        return httpretty.register_uri(method, *argc, **kwargs)
591b7e
 
591b7e
     register_helper(myreg, base_url, data)
591b7e
 
591b7e
@@ -237,6 +239,8 @@ class TestEc2(test_helpers.HttprettyTestCase):
591b7e
         if md:
591b7e
             all_versions = (
591b7e
                 [ds.min_metadata_version] + ds.extended_metadata_versions)
591b7e
+            token_url = self.data_url('latest', data_item='api/token')
591b7e
+            register_mock_metaserver(token_url, 'API-TOKEN')
591b7e
             for version in all_versions:
591b7e
                 metadata_url = self.data_url(version) + '/'
591b7e
                 if version == md_version:
591b7e
-- 
591b7e
1.8.3.1
591b7e