← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~falzm/cloud-init:exoscale-datasource into cloud-init:master

 

Marc Falzon has proposed merging ~falzm/cloud-init:exoscale-datasource into cloud-init:master.

Commit message:
Add new Exoscale data source

Requested reviews:
  cloud-init commiters (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~falzm/cloud-init/+git/cloud-init/+merge/369090

This change introduces a new data source for the Exoscale Cloud (https://www.exoscale.com/).
-- 
Your team cloud-init commiters is requested to review the proposed merge of ~falzm/cloud-init:exoscale-datasource into cloud-init:master.
diff --git a/cloudinit/apport.py b/cloudinit/apport.py
index 22cb7fd..003ff1f 100644
--- a/cloudinit/apport.py
+++ b/cloudinit/apport.py
@@ -23,6 +23,7 @@ KNOWN_CLOUD_NAMES = [
     'CloudStack',
     'DigitalOcean',
     'GCE - Google Compute Engine',
+    'Exoscale',
     'Hetzner Cloud',
     'IBM - (aka SoftLayer or BlueMix)',
     'LXD',
diff --git a/cloudinit/settings.py b/cloudinit/settings.py
index b1ebaad..2060d81 100644
--- a/cloudinit/settings.py
+++ b/cloudinit/settings.py
@@ -39,6 +39,7 @@ CFG_BUILTIN = {
         'Hetzner',
         'IBMCloud',
         'Oracle',
+        'Exoscale',
         # At the end to act as a 'catch' when none of the above work...
         'None',
     ],
diff --git a/cloudinit/sources/DataSourceExoscale.py b/cloudinit/sources/DataSourceExoscale.py
new file mode 100644
index 0000000..4588c9d
--- /dev/null
+++ b/cloudinit/sources/DataSourceExoscale.py
@@ -0,0 +1,126 @@
+import time
+
+from cloudinit import ec2_utils as ec2
+from cloudinit import log as logging
+from cloudinit import sources
+from cloudinit import url_helper
+
+API_VERSION = "latest"
+LOG = logging.getLogger(__name__)
+SERVICE_ADDRESS = "http://169.254.169.254";
+
+
+class DataSourceExoscale(sources.DataSource):
+
+    dsname = 'Exoscale'
+    url_timeout = 10
+    url_retries = 6
+    url_max_wait = 60
+
+    def __init__(self, sys_cfg, distro, paths):
+        sources.DataSource.__init__(self, sys_cfg, distro, paths)
+        LOG.info("Initializing the Exoscale datasource")
+        self.extra_config = {}
+
+    def get_password(self):
+        """Return the VM's passwords."""
+        LOG.info("Fetching password from metadata service")
+        password_url = "{}:8080".format(SERVICE_ADDRESS)
+        response = url_helper.read_file_or_url(
+            password_url,
+            ssl_details=None,
+            headers={"DomU_Request": "send_my_password"},
+            timeout=self.url_timeout,
+            retries=self.url_retries)
+        password = response.contents.decode('utf-8')
+        # the password is empty or already saved
+        if password in ['', 'saved_password']:
+            LOG.info("Password is missing or already saved")
+            return None
+        LOG.info("Found the password in metadata service")
+        # save the password
+        url_helper.read_file_or_url(
+            password_url,
+            ssl_details=None,
+            headers={"DomU_Request": "saved_password"},
+            timeout=self.url_timeout,
+            retries=self.url_retries)
+        LOG.info("password saved")
+        return password
+
+    def wait_for_metadata_service(self):
+        """Wait for the metadata service to be reachable."""
+        LOG.info("waiting for the metadata service")
+        start_time = time.time()
+
+        metadata_url = "{}/{}/meta-data/instance-id".format(
+            SERVICE_ADDRESS,
+            API_VERSION)
+
+        start_time = time.time()
+        url = url_helper.wait_for_url(
+            urls=[metadata_url],
+            max_wait=self.url_max_wait,
+            timeout=self.url_timeout,
+            status_cb=LOG.critical)
+
+        if url:
+            LOG.info("metadata service ok")
+            return True
+        else:
+            wait_time = int(time.time() - start_time)
+            LOG.critical(("Giving up on waiting for the metadata from %s"
+                          " after %s seconds"),
+                         url,
+                         wait_time)
+            return False
+
+    def _get_data(self):
+        """Fetch the user data, the metadata and the VM password
+        from the metadata service."""
+        LOG.info("fetching data")
+        if not self.wait_for_metadata_service():
+            return False
+        start_time = time.time()
+        self.userdata_raw = ec2.get_instance_userdata(API_VERSION,
+                                                      SERVICE_ADDRESS,
+                                                      timeout=self.url_timeout,
+                                                      retries=self.url_retries)
+        self.metadata = ec2.get_instance_metadata(API_VERSION,
+                                                  SERVICE_ADDRESS,
+                                                  timeout=self.url_timeout,
+                                                  retries=self.url_retries)
+        password = self.get_password()
+        if password:
+            self.extra_config = {
+                'ssh_pwauth': True,
+                'password': password,
+                'chpasswd': {
+                    'expire': False,
+                },
+            }
+        get_data_time = int(time.time() - start_time)
+        LOG.info("finished fetching the metadata in %s seconds",
+                 get_data_time)
+        return True
+
+    def get_config_obj(self):
+        return self.extra_config
+
+    def get_instance_id(self):
+        return self.metadata['instance-id']
+
+    @property
+    def availability_zone(self):
+        return self.metadata['availability-zone']
+
+
+# Used to match classes to dependencies
+datasources = [
+    (DataSourceExoscale, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
+]
+
+
+# Return a list of data sources that match this set of dependencies
+def get_datasource_list(depends):
+    return sources.list_from_depends(depends, datasources)
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
index 6b01a4e..24b0fac 100644
--- a/tests/unittests/test_datasource/test_common.py
+++ b/tests/unittests/test_datasource/test_common.py
@@ -13,6 +13,7 @@ from cloudinit.sources import (
     DataSourceConfigDrive as ConfigDrive,
     DataSourceDigitalOcean as DigitalOcean,
     DataSourceEc2 as Ec2,
+    DataSourceExoscale as Exoscale,
     DataSourceGCE as GCE,
     DataSourceHetzner as Hetzner,
     DataSourceIBMCloud as IBMCloud,
@@ -53,6 +54,7 @@ DEFAULT_NETWORK = [
     CloudStack.DataSourceCloudStack,
     DSNone.DataSourceNone,
     Ec2.DataSourceEc2,
+    Exoscale.DataSourceExoscale,
     GCE.DataSourceGCE,
     MAAS.DataSourceMAAS,
     NoCloud.DataSourceNoCloudNet,
diff --git a/tests/unittests/test_datasource/test_exoscale.py b/tests/unittests/test_datasource/test_exoscale.py
new file mode 100644
index 0000000..4bb9379
--- /dev/null
+++ b/tests/unittests/test_datasource/test_exoscale.py
@@ -0,0 +1,93 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+from cloudinit import helpers
+from cloudinit.sources.DataSourceExoscale import (
+    API_VERSION,
+    DataSourceExoscale,
+    SERVICE_ADDRESS)
+from cloudinit.tests.helpers import HttprettyTestCase
+
+import httpretty
+
+
+@httpretty.activate
+class TestDatasourceExoscale(HttprettyTestCase):
+
+    def setUp(self):
+        super(TestDatasourceExoscale, self).setUp()
+        self.tmp = self.tmp_dir()
+
+        self.password_url = "{}:8080/".format(SERVICE_ADDRESS)
+        self.metadata_url = "{}/{}/meta-data/".format(SERVICE_ADDRESS,
+                                                      API_VERSION)
+        self.userdata_url = "{}/{}/user-data".format(SERVICE_ADDRESS,
+                                                     API_VERSION)
+
+    def test_password_saved(self):
+        """The password is not set when it is not found
+        in the metadata service."""
+        path = helpers.Paths({'run_dir': self.tmp})
+        ds = DataSourceExoscale({}, None, path)
+        httpretty.register_uri(httpretty.GET,
+                               self.password_url,
+                               body="saved_password")
+        self.assertFalse(ds.get_password())
+
+    def test_password_empty(self):
+        """No password is set if the metadata service returns
+        an empty string."""
+        path = helpers.Paths({'run_dir': self.tmp})
+        ds = DataSourceExoscale({}, None, path)
+        httpretty.register_uri(httpretty.GET,
+                               self.password_url,
+                               body="")
+        self.assertFalse(ds.get_password())
+
+    def test_password(self):
+        """The password is set to what is found in the metadata
+        service."""
+        path = helpers.Paths({'run_dir': self.tmp})
+        ds = DataSourceExoscale({}, None, path)
+        expected_password = "p@ssw0rd"
+        httpretty.register_uri(httpretty.GET,
+                               self.password_url,
+                               body=expected_password)
+        password = ds.get_password()
+        self.assertEqual(expected_password, password)
+
+    def test_get_data(self):
+        """The datasource conforms to expected behavior when supplied
+        full test data."""
+        path = helpers.Paths({'run_dir': self.tmp})
+        ds = DataSourceExoscale({}, None, path)
+        expected_password = "p@ssw0rd"
+        expected_id = "12345"
+        expected_hostname = "myname"
+        expected_userdata = "#cloud-config"
+        httpretty.register_uri(httpretty.GET,
+                               self.userdata_url,
+                               body=expected_userdata)
+        httpretty.register_uri(httpretty.GET,
+                               self.password_url,
+                               body=expected_password)
+        httpretty.register_uri(httpretty.GET,
+                               self.metadata_url,
+                               body="instance-id\nlocal-hostname")
+        httpretty.register_uri(httpretty.GET,
+                               "{}local-hostname".format(self.metadata_url),
+                               body=expected_hostname)
+        httpretty.register_uri(httpretty.GET,
+                               "{}local-hostname".format(self.metadata_url),
+                               body=expected_hostname)
+        httpretty.register_uri(httpretty.GET,
+                               "{}instance-id".format(self.metadata_url),
+                               body=expected_id)
+        ds._get_data()
+        self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
+        self.assertEqual(ds.metadata, {"instance-id": expected_id,
+                                       "local-hostname": expected_hostname})
+        self.assertEqual(ds.get_config_obj(),
+                         {'ssh_pwauth': True,
+                          'password': expected_password,
+                          'chpasswd': {
+                              'expire': False,
+                          }})
diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
index 0e71db8..882cbf0 100644
--- a/tests/unittests/test_util.py
+++ b/tests/unittests/test_util.py
@@ -855,14 +855,6 @@ class TestSubp(helpers.CiTestCase):
                                    r'Missing #! in script\?',
                                    util.subp, (noshebang,))
 
-    def test_subp_combined_stderr_stdout(self):
-        """Providing combine_capture as True redirects stderr to stdout."""
-        data = b'hello world'
-        (out, err) = util.subp(self.stdin2err, capture=True,
-                               combine_capture=True, decode=False, data=data)
-        self.assertEqual(b'', err)
-        self.assertEqual(data, out)
-
     def test_returns_none_if_no_capture(self):
         (out, err) = util.subp(self.stdin2out, data=b'', capture=False)
         self.assertIsNone(err)
diff --git a/tools/ds-identify b/tools/ds-identify
index e16708f..5727c24 100755
--- a/tools/ds-identify
+++ b/tools/ds-identify
@@ -124,7 +124,7 @@ DI_DSNAME=""
 # be searched if there is no setting found in config.
 DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
 CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \
-OVF SmartOS Scaleway Hetzner IBMCloud Oracle"
+OVF SmartOS Scaleway Hetzner IBMCloud Oracle Exoscale"
 DI_DSLIST=""
 DI_MODE=""
 DI_ON_FOUND=""
@@ -553,6 +553,12 @@ dscheck_CloudStack() {
     return $DS_NOT_FOUND
 }
 
+dscheck_Exoscale() {
+    is_container && return ${DS_NOT_FOUND}
+    dmi_product_name_matches "Exoscale*" && return $DS_FOUND
+    return $DS_NOT_FOUND
+}
+
 dscheck_CloudSigma() {
     # http://paste.ubuntu.com/23624795/
     dmi_product_name_matches "CloudSigma" && return $DS_FOUND

Follow ups