|
|
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 |
|