faf1e5
From ccae8d2ac218366c529aac03b29c46400843d4a0 Mon Sep 17 00:00:00 2001
faf1e5
From: Eduardo Otubo <otubo@redhat.com>
faf1e5
Date: Tue, 5 May 2020 08:08:09 +0200
faf1e5
Subject: [PATCH 1/5] New data source for the Exoscale.com cloud platform
faf1e5
faf1e5
RH-Author: Eduardo Otubo <otubo@redhat.com>
faf1e5
Message-id: <20200504085238.25884-2-otubo@redhat.com>
faf1e5
Patchwork-id: 96244
faf1e5
O-Subject: [RHEL-7.8.z cloud-init PATCH 1/5] New data source for the Exoscale.com cloud platform
faf1e5
Bugzilla: 1827207
faf1e5
RH-Acked-by: Cathy Avery <cavery@redhat.com>
faf1e5
RH-Acked-by: Mohammed Gamal <mgamal@redhat.com>
faf1e5
RH-Acked-by: Vitaly Kuznetsov <vkuznets@redhat.com>
faf1e5
faf1e5
commit 4dfed67d0e82970f8717d0b524c593962698ca4f
faf1e5
Author: Chris Glass <christopher.glass@exoscale.ch>
faf1e5
Date:   Thu Aug 8 17:09:57 2019 +0000
faf1e5
faf1e5
    New data source for the Exoscale.com cloud platform
faf1e5
faf1e5
    - dsidentify switches to the new Exoscale datasource on matching DMI name
faf1e5
    - New Exoscale datasource added
faf1e5
faf1e5
    Signed-off-by: Mathieu Corbin <mathieu.corbin@exoscale.ch>
faf1e5
faf1e5
Signed-off-by: Eduardo Otubo <otubo@redhat.com>
faf1e5
Signed-off-by: Miroslav Rezanina <mrezanin@redhat.com>
faf1e5
---
faf1e5
 cloudinit/apport.py                              |   1 +
faf1e5
 cloudinit/settings.py                            |   1 +
faf1e5
 cloudinit/sources/DataSourceExoscale.py          | 258 +++++++++++++++++++++++
faf1e5
 doc/rtd/topics/datasources.rst                   |   1 +
faf1e5
 doc/rtd/topics/datasources/exoscale.rst          |  68 ++++++
faf1e5
 tests/unittests/test_datasource/test_common.py   |   2 +
faf1e5
 tests/unittests/test_datasource/test_exoscale.py | 203 ++++++++++++++++++
faf1e5
 tools/ds-identify                                |   7 +-
faf1e5
 8 files changed, 540 insertions(+), 1 deletion(-)
faf1e5
 create mode 100644 cloudinit/sources/DataSourceExoscale.py
faf1e5
 create mode 100644 doc/rtd/topics/datasources/exoscale.rst
faf1e5
 create mode 100644 tests/unittests/test_datasource/test_exoscale.py
faf1e5
faf1e5
diff --git a/cloudinit/apport.py b/cloudinit/apport.py
faf1e5
index 22cb7fd..003ff1f 100644
faf1e5
--- a/cloudinit/apport.py
faf1e5
+++ b/cloudinit/apport.py
faf1e5
@@ -23,6 +23,7 @@ KNOWN_CLOUD_NAMES = [
faf1e5
     'CloudStack',
faf1e5
     'DigitalOcean',
faf1e5
     'GCE - Google Compute Engine',
faf1e5
+    'Exoscale',
faf1e5
     'Hetzner Cloud',
faf1e5
     'IBM - (aka SoftLayer or BlueMix)',
faf1e5
     'LXD',
faf1e5
diff --git a/cloudinit/settings.py b/cloudinit/settings.py
faf1e5
index d982a4d..229b420 100644
faf1e5
--- a/cloudinit/settings.py
faf1e5
+++ b/cloudinit/settings.py
faf1e5
@@ -39,6 +39,7 @@ CFG_BUILTIN = {
faf1e5
         'Hetzner',
faf1e5
         'IBMCloud',
faf1e5
         'Oracle',
faf1e5
+        'Exoscale',
faf1e5
         # At the end to act as a 'catch' when none of the above work...
faf1e5
         'None',
faf1e5
     ],
faf1e5
diff --git a/cloudinit/sources/DataSourceExoscale.py b/cloudinit/sources/DataSourceExoscale.py
faf1e5
new file mode 100644
faf1e5
index 0000000..52e7f6f
faf1e5
--- /dev/null
faf1e5
+++ b/cloudinit/sources/DataSourceExoscale.py
faf1e5
@@ -0,0 +1,258 @@
faf1e5
+# Author: Mathieu Corbin <mathieu.corbin@exoscale.com>
faf1e5
+# Author: Christopher Glass <christopher.glass@exoscale.com>
faf1e5
+#
faf1e5
+# This file is part of cloud-init. See LICENSE file for license information.
faf1e5
+
faf1e5
+from cloudinit import ec2_utils as ec2
faf1e5
+from cloudinit import log as logging
faf1e5
+from cloudinit import sources
faf1e5
+from cloudinit import url_helper
faf1e5
+from cloudinit import util
faf1e5
+
faf1e5
+LOG = logging.getLogger(__name__)
faf1e5
+
faf1e5
+METADATA_URL = "http://169.254.169.254"
faf1e5
+API_VERSION = "1.0"
faf1e5
+PASSWORD_SERVER_PORT = 8080
faf1e5
+
faf1e5
+URL_TIMEOUT = 10
faf1e5
+URL_RETRIES = 6
faf1e5
+
faf1e5
+EXOSCALE_DMI_NAME = "Exoscale"
faf1e5
+
faf1e5
+BUILTIN_DS_CONFIG = {
faf1e5
+    # We run the set password config module on every boot in order to enable
faf1e5
+    # resetting the instance's password via the exoscale console (and a
faf1e5
+    # subsequent instance reboot).
faf1e5
+    'cloud_config_modules': [["set-passwords", "always"]]
faf1e5
+}
faf1e5
+
faf1e5
+
faf1e5
+class DataSourceExoscale(sources.DataSource):
faf1e5
+
faf1e5
+    dsname = 'Exoscale'
faf1e5
+
faf1e5
+    def __init__(self, sys_cfg, distro, paths):
faf1e5
+        super(DataSourceExoscale, self).__init__(sys_cfg, distro, paths)
faf1e5
+        LOG.debug("Initializing the Exoscale datasource")
faf1e5
+
faf1e5
+        self.metadata_url = self.ds_cfg.get('metadata_url', METADATA_URL)
faf1e5
+        self.api_version = self.ds_cfg.get('api_version', API_VERSION)
faf1e5
+        self.password_server_port = int(
faf1e5
+            self.ds_cfg.get('password_server_port', PASSWORD_SERVER_PORT))
faf1e5
+        self.url_timeout = self.ds_cfg.get('timeout', URL_TIMEOUT)
faf1e5
+        self.url_retries = self.ds_cfg.get('retries', URL_RETRIES)
faf1e5
+
faf1e5
+        self.extra_config = BUILTIN_DS_CONFIG
faf1e5
+
faf1e5
+    def wait_for_metadata_service(self):
faf1e5
+        """Wait for the metadata service to be reachable."""
faf1e5
+
faf1e5
+        metadata_url = "{}/{}/meta-data/instance-id".format(
faf1e5
+            self.metadata_url, self.api_version)
faf1e5
+
faf1e5
+        url = url_helper.wait_for_url(
faf1e5
+            urls=[metadata_url],
faf1e5
+            max_wait=self.url_max_wait,
faf1e5
+            timeout=self.url_timeout,
faf1e5
+            status_cb=LOG.critical)
faf1e5
+
faf1e5
+        return bool(url)
faf1e5
+
faf1e5
+    def crawl_metadata(self):
faf1e5
+        """
faf1e5
+        Crawl the metadata service when available.
faf1e5
+
faf1e5
+        @returns: Dictionary of crawled metadata content.
faf1e5
+        """
faf1e5
+        metadata_ready = util.log_time(
faf1e5
+            logfunc=LOG.info,
faf1e5
+            msg='waiting for the metadata service',
faf1e5
+            func=self.wait_for_metadata_service)
faf1e5
+
faf1e5
+        if not metadata_ready:
faf1e5
+            return {}
faf1e5
+
faf1e5
+        return read_metadata(self.metadata_url, self.api_version,
faf1e5
+                             self.password_server_port, self.url_timeout,
faf1e5
+                             self.url_retries)
faf1e5
+
faf1e5
+    def _get_data(self):
faf1e5
+        """Fetch the user data, the metadata and the VM password
faf1e5
+        from the metadata service.
faf1e5
+
faf1e5
+        Please refer to the datasource documentation for details on how the
faf1e5
+        metadata server and password server are crawled.
faf1e5
+        """
faf1e5
+        if not self._is_platform_viable():
faf1e5
+            return False
faf1e5
+
faf1e5
+        data = util.log_time(
faf1e5
+            logfunc=LOG.debug,
faf1e5
+            msg='Crawl of metadata service',
faf1e5
+            func=self.crawl_metadata)
faf1e5
+
faf1e5
+        if not data:
faf1e5
+            return False
faf1e5
+
faf1e5
+        self.userdata_raw = data['user-data']
faf1e5
+        self.metadata = data['meta-data']
faf1e5
+        password = data.get('password')
faf1e5
+
faf1e5
+        password_config = {}
faf1e5
+        if password:
faf1e5
+            # Since we have a password, let's make sure we are allowed to use
faf1e5
+            # it by allowing ssh_pwauth.
faf1e5
+            # The password module's default behavior is to leave the
faf1e5
+            # configuration as-is in this regard, so that means it will either
faf1e5
+            # leave the password always disabled if no password is ever set, or
faf1e5
+            # leave the password login enabled if we set it once.
faf1e5
+            password_config = {
faf1e5
+                'ssh_pwauth': True,
faf1e5
+                'password': password,
faf1e5
+                'chpasswd': {
faf1e5
+                    'expire': False,
faf1e5
+                },
faf1e5
+            }
faf1e5
+
faf1e5
+        # builtin extra_config overrides password_config
faf1e5
+        self.extra_config = util.mergemanydict(
faf1e5
+            [self.extra_config, password_config])
faf1e5
+
faf1e5
+        return True
faf1e5
+
faf1e5
+    def get_config_obj(self):
faf1e5
+        return self.extra_config
faf1e5
+
faf1e5
+    def _is_platform_viable(self):
faf1e5
+        return util.read_dmi_data('system-product-name').startswith(
faf1e5
+            EXOSCALE_DMI_NAME)
faf1e5
+
faf1e5
+
faf1e5
+# Used to match classes to dependencies
faf1e5
+datasources = [
faf1e5
+    (DataSourceExoscale, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
faf1e5
+]
faf1e5
+
faf1e5
+
faf1e5
+# Return a list of data sources that match this set of dependencies
faf1e5
+def get_datasource_list(depends):
faf1e5
+    return sources.list_from_depends(depends, datasources)
faf1e5
+
faf1e5
+
faf1e5
+def get_password(metadata_url=METADATA_URL,
faf1e5
+                 api_version=API_VERSION,
faf1e5
+                 password_server_port=PASSWORD_SERVER_PORT,
faf1e5
+                 url_timeout=URL_TIMEOUT,
faf1e5
+                 url_retries=URL_RETRIES):
faf1e5
+    """Obtain the VM's password if set.
faf1e5
+
faf1e5
+    Once fetched the password is marked saved. Future calls to this method may
faf1e5
+    return empty string or 'saved_password'."""
faf1e5
+    password_url = "{}:{}/{}/".format(metadata_url, password_server_port,
faf1e5
+                                      api_version)
faf1e5
+    response = url_helper.read_file_or_url(
faf1e5
+        password_url,
faf1e5
+        ssl_details=None,
faf1e5
+        headers={"DomU_Request": "send_my_password"},
faf1e5
+        timeout=url_timeout,
faf1e5
+        retries=url_retries)
faf1e5
+    password = response.contents.decode('utf-8')
faf1e5
+    # the password is empty or already saved
faf1e5
+    # Note: the original metadata server would answer an additional
faf1e5
+    # 'bad_request' status, but the Exoscale implementation does not.
faf1e5
+    if password in ['', 'saved_password']:
faf1e5
+        return None
faf1e5
+    # save the password
faf1e5
+    url_helper.read_file_or_url(
faf1e5
+        password_url,
faf1e5
+        ssl_details=None,
faf1e5
+        headers={"DomU_Request": "saved_password"},
faf1e5
+        timeout=url_timeout,
faf1e5
+        retries=url_retries)
faf1e5
+    return password
faf1e5
+
faf1e5
+
faf1e5
+def read_metadata(metadata_url=METADATA_URL,
faf1e5
+                  api_version=API_VERSION,
faf1e5
+                  password_server_port=PASSWORD_SERVER_PORT,
faf1e5
+                  url_timeout=URL_TIMEOUT,
faf1e5
+                  url_retries=URL_RETRIES):
faf1e5
+    """Query the metadata server and return the retrieved data."""
faf1e5
+    crawled_metadata = {}
faf1e5
+    crawled_metadata['_metadata_api_version'] = api_version
faf1e5
+    try:
faf1e5
+        crawled_metadata['user-data'] = ec2.get_instance_userdata(
faf1e5
+            api_version,
faf1e5
+            metadata_url,
faf1e5
+            timeout=url_timeout,
faf1e5
+            retries=url_retries)
faf1e5
+        crawled_metadata['meta-data'] = ec2.get_instance_metadata(
faf1e5
+            api_version,
faf1e5
+            metadata_url,
faf1e5
+            timeout=url_timeout,
faf1e5
+            retries=url_retries)
faf1e5
+    except Exception as e:
faf1e5
+        util.logexc(LOG, "failed reading from metadata url %s (%s)",
faf1e5
+                    metadata_url, e)
faf1e5
+        return {}
faf1e5
+
faf1e5
+    try:
faf1e5
+        crawled_metadata['password'] = get_password(
faf1e5
+            api_version=api_version,
faf1e5
+            metadata_url=metadata_url,
faf1e5
+            password_server_port=password_server_port,
faf1e5
+            url_retries=url_retries,
faf1e5
+            url_timeout=url_timeout)
faf1e5
+    except Exception as e:
faf1e5
+        util.logexc(LOG, "failed to read from password server url %s:%s (%s)",
faf1e5
+                    metadata_url, password_server_port, e)
faf1e5
+
faf1e5
+    return crawled_metadata
faf1e5
+
faf1e5
+
faf1e5
+if __name__ == "__main__":
faf1e5
+    import argparse
faf1e5
+
faf1e5
+    parser = argparse.ArgumentParser(description='Query Exoscale Metadata')
faf1e5
+    parser.add_argument(
faf1e5
+        "--endpoint",
faf1e5
+        metavar="URL",
faf1e5
+        help="The url of the metadata service.",
faf1e5
+        default=METADATA_URL)
faf1e5
+    parser.add_argument(
faf1e5
+        "--version",
faf1e5
+        metavar="VERSION",
faf1e5
+        help="The version of the metadata endpoint to query.",
faf1e5
+        default=API_VERSION)
faf1e5
+    parser.add_argument(
faf1e5
+        "--retries",
faf1e5
+        metavar="NUM",
faf1e5
+        type=int,
faf1e5
+        help="The number of retries querying the endpoint.",
faf1e5
+        default=URL_RETRIES)
faf1e5
+    parser.add_argument(
faf1e5
+        "--timeout",
faf1e5
+        metavar="NUM",
faf1e5
+        type=int,
faf1e5
+        help="The time in seconds to wait before timing out.",
faf1e5
+        default=URL_TIMEOUT)
faf1e5
+    parser.add_argument(
faf1e5
+        "--password-port",
faf1e5
+        metavar="PORT",
faf1e5
+        type=int,
faf1e5
+        help="The port on which the password endpoint listens",
faf1e5
+        default=PASSWORD_SERVER_PORT)
faf1e5
+
faf1e5
+    args = parser.parse_args()
faf1e5
+
faf1e5
+    data = read_metadata(
faf1e5
+        metadata_url=args.endpoint,
faf1e5
+        api_version=args.version,
faf1e5
+        password_server_port=args.password_port,
faf1e5
+        url_timeout=args.timeout,
faf1e5
+        url_retries=args.retries)
faf1e5
+
faf1e5
+    print(util.json_dumps(data))
faf1e5
+
faf1e5
+# vi: ts=4 expandtab
faf1e5
diff --git a/doc/rtd/topics/datasources.rst b/doc/rtd/topics/datasources.rst
faf1e5
index e34f145..fcfd91a 100644
faf1e5
--- a/doc/rtd/topics/datasources.rst
faf1e5
+++ b/doc/rtd/topics/datasources.rst
faf1e5
@@ -96,6 +96,7 @@ Follow for more information.
faf1e5
    datasources/configdrive.rst
faf1e5
    datasources/digitalocean.rst
faf1e5
    datasources/ec2.rst
faf1e5
+   datasources/exoscale.rst
faf1e5
    datasources/maas.rst
faf1e5
    datasources/nocloud.rst
faf1e5
    datasources/opennebula.rst
faf1e5
diff --git a/doc/rtd/topics/datasources/exoscale.rst b/doc/rtd/topics/datasources/exoscale.rst
faf1e5
new file mode 100644
faf1e5
index 0000000..27aec9c
faf1e5
--- /dev/null
faf1e5
+++ b/doc/rtd/topics/datasources/exoscale.rst
faf1e5
@@ -0,0 +1,68 @@
faf1e5
+.. _datasource_exoscale:
faf1e5
+
faf1e5
+Exoscale
faf1e5
+========
faf1e5
+
faf1e5
+This datasource supports reading from the metadata server used on the
faf1e5
+`Exoscale platform <https://exoscale.com>`_.
faf1e5
+
faf1e5
+Use of the Exoscale datasource is recommended to benefit from new features of
faf1e5
+the Exoscale platform.
faf1e5
+
faf1e5
+The datasource relies on the availability of a compatible metadata server
faf1e5
+(``http://169.254.169.254`` is used by default) and its companion password
faf1e5
+server, reachable at the same address (by default on port 8080).
faf1e5
+
faf1e5
+Crawling of metadata
faf1e5
+--------------------
faf1e5
+
faf1e5
+The metadata service and password server are crawled slightly differently:
faf1e5
+
faf1e5
+ * The "metadata service" is crawled every boot.
faf1e5
+ * The password server is also crawled every boot (the Exoscale datasource
faf1e5
+   forces the password module to run with "frequency always").
faf1e5
+
faf1e5
+In the password server case, the following rules apply in order to enable the
faf1e5
+"restore instance password" functionality:
faf1e5
+
faf1e5
+ * If a password is returned by the password server, it is then marked "saved"
faf1e5
+   by the cloud-init datasource. Subsequent boots will skip setting the password
faf1e5
+   (the password server will return "saved_password").
faf1e5
+ * When the instance password is reset (via the Exoscale UI), the password
faf1e5
+   server will return the non-empty password at next boot, therefore causing
faf1e5
+   cloud-init to reset the instance's password.
faf1e5
+
faf1e5
+Configuration
faf1e5
+-------------
faf1e5
+
faf1e5
+Users of this datasource are discouraged from changing the default settings
faf1e5
+unless instructed to by Exoscale support.
faf1e5
+
faf1e5
+The following settings are available and can be set for the datasource in system
faf1e5
+configuration (in `/etc/cloud/cloud.cfg.d/`).
faf1e5
+
faf1e5
+The settings available are:
faf1e5
+
faf1e5
+ * **metadata_url**: The URL for the metadata service (defaults to
faf1e5
+   ``http://169.254.169.254``)
faf1e5
+ * **api_version**: The API version path on which to query the instance metadata
faf1e5
+   (defaults to ``1.0``)
faf1e5
+ * **password_server_port**: The port (on the metadata server) on which the
faf1e5
+   password server listens (defaults to ``8080``).
faf1e5
+ * **timeout**: the timeout value provided to urlopen for each individual http
faf1e5
+   request. (defaults to ``10``)
faf1e5
+ * **retries**: The number of retries that should be done for an http request
faf1e5
+   (defaults to ``6``)
faf1e5
+
faf1e5
+
faf1e5
+An example configuration with the default values is provided below:
faf1e5
+
faf1e5
+.. sourcecode:: yaml
faf1e5
+
faf1e5
+   datasource:
faf1e5
+     Exoscale:
faf1e5
+       metadata_url: "http://169.254.169.254"
faf1e5
+       api_version: "1.0"
faf1e5
+       password_server_port: 8080
faf1e5
+       timeout: 10
faf1e5
+       retries: 6
faf1e5
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
faf1e5
index 6b01a4e..24b0fac 100644
faf1e5
--- a/tests/unittests/test_datasource/test_common.py
faf1e5
+++ b/tests/unittests/test_datasource/test_common.py
faf1e5
@@ -13,6 +13,7 @@ from cloudinit.sources import (
faf1e5
     DataSourceConfigDrive as ConfigDrive,
faf1e5
     DataSourceDigitalOcean as DigitalOcean,
faf1e5
     DataSourceEc2 as Ec2,
faf1e5
+    DataSourceExoscale as Exoscale,
faf1e5
     DataSourceGCE as GCE,
faf1e5
     DataSourceHetzner as Hetzner,
faf1e5
     DataSourceIBMCloud as IBMCloud,
faf1e5
@@ -53,6 +54,7 @@ DEFAULT_NETWORK = [
faf1e5
     CloudStack.DataSourceCloudStack,
faf1e5
     DSNone.DataSourceNone,
faf1e5
     Ec2.DataSourceEc2,
faf1e5
+    Exoscale.DataSourceExoscale,
faf1e5
     GCE.DataSourceGCE,
faf1e5
     MAAS.DataSourceMAAS,
faf1e5
     NoCloud.DataSourceNoCloudNet,
faf1e5
diff --git a/tests/unittests/test_datasource/test_exoscale.py b/tests/unittests/test_datasource/test_exoscale.py
faf1e5
new file mode 100644
faf1e5
index 0000000..350c330
faf1e5
--- /dev/null
faf1e5
+++ b/tests/unittests/test_datasource/test_exoscale.py
faf1e5
@@ -0,0 +1,203 @@
faf1e5
+# Author: Mathieu Corbin <mathieu.corbin@exoscale.com>
faf1e5
+# Author: Christopher Glass <christopher.glass@exoscale.com>
faf1e5
+#
faf1e5
+# This file is part of cloud-init. See LICENSE file for license information.
faf1e5
+from cloudinit import helpers
faf1e5
+from cloudinit.sources.DataSourceExoscale import (
faf1e5
+    API_VERSION,
faf1e5
+    DataSourceExoscale,
faf1e5
+    METADATA_URL,
faf1e5
+    get_password,
faf1e5
+    PASSWORD_SERVER_PORT,
faf1e5
+    read_metadata)
faf1e5
+from cloudinit.tests.helpers import HttprettyTestCase, mock
faf1e5
+
faf1e5
+import httpretty
faf1e5
+import requests
faf1e5
+
faf1e5
+
faf1e5
+TEST_PASSWORD_URL = "{}:{}/{}/".format(METADATA_URL,
faf1e5
+                                       PASSWORD_SERVER_PORT,
faf1e5
+                                       API_VERSION)
faf1e5
+
faf1e5
+TEST_METADATA_URL = "{}/{}/meta-data/".format(METADATA_URL,
faf1e5
+                                              API_VERSION)
faf1e5
+
faf1e5
+TEST_USERDATA_URL = "{}/{}/user-data".format(METADATA_URL,
faf1e5
+                                             API_VERSION)
faf1e5
+
faf1e5
+
faf1e5
+@httpretty.activate
faf1e5
+class TestDatasourceExoscale(HttprettyTestCase):
faf1e5
+
faf1e5
+    def setUp(self):
faf1e5
+        super(TestDatasourceExoscale, self).setUp()
faf1e5
+        self.tmp = self.tmp_dir()
faf1e5
+        self.password_url = TEST_PASSWORD_URL
faf1e5
+        self.metadata_url = TEST_METADATA_URL
faf1e5
+        self.userdata_url = TEST_USERDATA_URL
faf1e5
+
faf1e5
+    def test_password_saved(self):
faf1e5
+        """The password is not set when it is not found
faf1e5
+        in the metadata service."""
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.password_url,
faf1e5
+                               body="saved_password")
faf1e5
+        self.assertFalse(get_password())
faf1e5
+
faf1e5
+    def test_password_empty(self):
faf1e5
+        """No password is set if the metadata service returns
faf1e5
+        an empty string."""
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.password_url,
faf1e5
+                               body="")
faf1e5
+        self.assertFalse(get_password())
faf1e5
+
faf1e5
+    def test_password(self):
faf1e5
+        """The password is set to what is found in the metadata
faf1e5
+        service."""
faf1e5
+        expected_password = "p@ssw0rd"
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.password_url,
faf1e5
+                               body=expected_password)
faf1e5
+        password = get_password()
faf1e5
+        self.assertEqual(expected_password, password)
faf1e5
+
faf1e5
+    def test_get_data(self):
faf1e5
+        """The datasource conforms to expected behavior when supplied
faf1e5
+        full test data."""
faf1e5
+        path = helpers.Paths({'run_dir': self.tmp})
faf1e5
+        ds = DataSourceExoscale({}, None, path)
faf1e5
+        ds._is_platform_viable = lambda: True
faf1e5
+        expected_password = "p@ssw0rd"
faf1e5
+        expected_id = "12345"
faf1e5
+        expected_hostname = "myname"
faf1e5
+        expected_userdata = "#cloud-config"
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.userdata_url,
faf1e5
+                               body=expected_userdata)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.password_url,
faf1e5
+                               body=expected_password)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.metadata_url,
faf1e5
+                               body="instance-id\nlocal-hostname")
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}local-hostname".format(self.metadata_url),
faf1e5
+                               body=expected_hostname)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}instance-id".format(self.metadata_url),
faf1e5
+                               body=expected_id)
faf1e5
+        self.assertTrue(ds._get_data())
faf1e5
+        self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
faf1e5
+        self.assertEqual(ds.metadata, {"instance-id": expected_id,
faf1e5
+                                       "local-hostname": expected_hostname})
faf1e5
+        self.assertEqual(ds.get_config_obj(),
faf1e5
+                         {'ssh_pwauth': True,
faf1e5
+                          'password': expected_password,
faf1e5
+                          'cloud_config_modules': [
faf1e5
+                              ["set-passwords", "always"]],
faf1e5
+                          'chpasswd': {
faf1e5
+                              'expire': False,
faf1e5
+                          }})
faf1e5
+
faf1e5
+    def test_get_data_saved_password(self):
faf1e5
+        """The datasource conforms to expected behavior when saved_password is
faf1e5
+        returned by the password server."""
faf1e5
+        path = helpers.Paths({'run_dir': self.tmp})
faf1e5
+        ds = DataSourceExoscale({}, None, path)
faf1e5
+        ds._is_platform_viable = lambda: True
faf1e5
+        expected_answer = "saved_password"
faf1e5
+        expected_id = "12345"
faf1e5
+        expected_hostname = "myname"
faf1e5
+        expected_userdata = "#cloud-config"
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.userdata_url,
faf1e5
+                               body=expected_userdata)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.password_url,
faf1e5
+                               body=expected_answer)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.metadata_url,
faf1e5
+                               body="instance-id\nlocal-hostname")
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}local-hostname".format(self.metadata_url),
faf1e5
+                               body=expected_hostname)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}instance-id".format(self.metadata_url),
faf1e5
+                               body=expected_id)
faf1e5
+        self.assertTrue(ds._get_data())
faf1e5
+        self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
faf1e5
+        self.assertEqual(ds.metadata, {"instance-id": expected_id,
faf1e5
+                                       "local-hostname": expected_hostname})
faf1e5
+        self.assertEqual(ds.get_config_obj(),
faf1e5
+                         {'cloud_config_modules': [
faf1e5
+                             ["set-passwords", "always"]]})
faf1e5
+
faf1e5
+    def test_get_data_no_password(self):
faf1e5
+        """The datasource conforms to expected behavior when no password is
faf1e5
+        returned by the password server."""
faf1e5
+        path = helpers.Paths({'run_dir': self.tmp})
faf1e5
+        ds = DataSourceExoscale({}, None, path)
faf1e5
+        ds._is_platform_viable = lambda: True
faf1e5
+        expected_answer = ""
faf1e5
+        expected_id = "12345"
faf1e5
+        expected_hostname = "myname"
faf1e5
+        expected_userdata = "#cloud-config"
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.userdata_url,
faf1e5
+                               body=expected_userdata)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.password_url,
faf1e5
+                               body=expected_answer)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.metadata_url,
faf1e5
+                               body="instance-id\nlocal-hostname")
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}local-hostname".format(self.metadata_url),
faf1e5
+                               body=expected_hostname)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}instance-id".format(self.metadata_url),
faf1e5
+                               body=expected_id)
faf1e5
+        self.assertTrue(ds._get_data())
faf1e5
+        self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
faf1e5
+        self.assertEqual(ds.metadata, {"instance-id": expected_id,
faf1e5
+                                       "local-hostname": expected_hostname})
faf1e5
+        self.assertEqual(ds.get_config_obj(),
faf1e5
+                         {'cloud_config_modules': [
faf1e5
+                             ["set-passwords", "always"]]})
faf1e5
+
faf1e5
+    @mock.patch('cloudinit.sources.DataSourceExoscale.get_password')
faf1e5
+    def test_read_metadata_when_password_server_unreachable(self, m_password):
faf1e5
+        """The read_metadata function returns partial results in case the
faf1e5
+        password server (only) is unreachable."""
faf1e5
+        expected_id = "12345"
faf1e5
+        expected_hostname = "myname"
faf1e5
+        expected_userdata = "#cloud-config"
faf1e5
+
faf1e5
+        m_password.side_effect = requests.Timeout('Fake Connection Timeout')
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.userdata_url,
faf1e5
+                               body=expected_userdata)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               self.metadata_url,
faf1e5
+                               body="instance-id\nlocal-hostname")
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}local-hostname".format(self.metadata_url),
faf1e5
+                               body=expected_hostname)
faf1e5
+        httpretty.register_uri(httpretty.GET,
faf1e5
+                               "{}instance-id".format(self.metadata_url),
faf1e5
+                               body=expected_id)
faf1e5
+
faf1e5
+        result = read_metadata()
faf1e5
+
faf1e5
+        self.assertIsNone(result.get("password"))
faf1e5
+        self.assertEqual(result.get("user-data").decode("utf-8"),
faf1e5
+                         expected_userdata)
faf1e5
+
faf1e5
+    def test_non_viable_platform(self):
faf1e5
+        """The datasource fails fast when the platform is not viable."""
faf1e5
+        path = helpers.Paths({'run_dir': self.tmp})
faf1e5
+        ds = DataSourceExoscale({}, None, path)
faf1e5
+        ds._is_platform_viable = lambda: False
faf1e5
+        self.assertFalse(ds._get_data())
faf1e5
diff --git a/tools/ds-identify b/tools/ds-identify
faf1e5
index 1acfeeb..6c89b06 100755
faf1e5
--- a/tools/ds-identify
faf1e5
+++ b/tools/ds-identify
faf1e5
@@ -124,7 +124,7 @@ DI_DSNAME=""
faf1e5
 # be searched if there is no setting found in config.
faf1e5
 DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
faf1e5
 CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \
faf1e5
-OVF SmartOS Scaleway Hetzner IBMCloud Oracle"
faf1e5
+OVF SmartOS Scaleway Hetzner IBMCloud Oracle Exoscale"
faf1e5
 DI_DSLIST=""
faf1e5
 DI_MODE=""
faf1e5
 DI_ON_FOUND=""
faf1e5
@@ -553,6 +553,11 @@ dscheck_CloudStack() {
faf1e5
     return $DS_NOT_FOUND
faf1e5
 }
faf1e5
 
faf1e5
+dscheck_Exoscale() {
faf1e5
+    dmi_product_name_matches "Exoscale*" && return $DS_FOUND
faf1e5
+    return $DS_NOT_FOUND
faf1e5
+}
faf1e5
+
faf1e5
 dscheck_CloudSigma() {
faf1e5
     # http://paste.ubuntu.com/23624795/
faf1e5
     dmi_product_name_matches "CloudSigma" && return $DS_FOUND
faf1e5
-- 
faf1e5
1.8.3.1
faf1e5